diff --git a/CMakeLists.txt b/CMakeLists.txt index c04cb848a..dede0182b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,7 +53,8 @@ include(GNUInstallDirs) option(ENABLE_TFE "Enable building “The Force Engine”" ON) option(ENABLE_SYSMIDI "Enable System-MIDI Output if RTMidi is available" ON) option(ENABLE_EDITOR "Enable TFE Editor" OFF) -option(ENABLE_ADJUSTABLEHUD_MOD "Install the build‑in “AdjustableHud mod” with TFE" ON) +option(ENABLE_OGV_CUTSCENES "Enable OGV (Ogg Theora) video cutscene support" OFF) +option(ENABLE_ADJUSTABLEHUD_MOD "Install the build‑in "AdjustableHud mod" with TFE" ON) if(ENABLE_TFE) add_executable(tfe) @@ -119,6 +120,17 @@ if(ENABLE_TFE) if(ENABLE_EDITOR) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -DBUILD_EDITOR") endif() + if(ENABLE_OGV_CUTSCENES) + if(UNIX) + pkg_check_modules(THEORA REQUIRED theoradec) + pkg_check_modules(OGG REQUIRED ogg) + pkg_check_modules(VORBIS REQUIRED vorbis vorbisfile) + target_include_directories(tfe PRIVATE ${THEORA_INCLUDE_DIRS} ${OGG_INCLUDE_DIRS} ${VORBIS_INCLUDE_DIRS}) + target_link_libraries(tfe PRIVATE ${THEORA_LIBRARIES} ${OGG_LIBRARIES} ${VORBIS_LIBRARIES}) + target_link_directories(tfe PRIVATE ${THEORA_LIBRARY_DIRS} ${OGG_LIBRARY_DIRS} ${VORBIS_LIBRARY_DIRS}) + endif() + add_definitions("-DENABLE_OGV_CUTSCENES") + endif() set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -DBUILD_FORCE_SCRIPT") diff --git a/TheForceEngine/Documentation/markdown/remaster-cutscenes/README.md b/TheForceEngine/Documentation/markdown/remaster-cutscenes/README.md new file mode 100644 index 000000000..b1ea7d6ae --- /dev/null +++ b/TheForceEngine/Documentation/markdown/remaster-cutscenes/README.md @@ -0,0 +1,73 @@ +# Remastered Cutscenes in The Force Engine + +TFE can play the Dark Forces Remaster's OGV cutscenes in place of the +original LFD FILM animations, with the original iMuse MIDI soundtrack +kept in perfect sync. The same system is open to modders: drop in your +own OGV + a tiny text script and TFE will play it. + +This documentation covers: + +| Doc | Who it's for | +|---|---| +| [architecture.md](architecture.md) | Anyone who wants to understand how the pipeline works end to end — what files get loaded from where, how MIDI cues dispatch against the video clock, how TFE's path resolution works. | +| [modding-guide.md](modding-guide.md) | Modders who want to **add or replace** a cutscene. Step-by-step walkthrough from an MP4 source to a playable scene. | +| [dcss-format.md](dcss-format.md) | Complete reference for the `.dcss` script format: every directive, every quirk, annotated examples from the stock remaster data. | +| [video-conversion.md](video-conversion.md) | Converting MP4/MKV/etc. to the OGV format TFE expects, with ffmpeg command lines that have been verified to produce working output. | +| [troubleshooting.md](troubleshooting.md) | When the cutscene doesn't play, the music is wrong, or subtitles don't show — start here. | + +## Quick start for modders + +1. Convert your video to OGV: + ```sh + ffmpeg -i mycutscene.mp4 -c:v libtheora -q:v 7 \ + -c:a libvorbis -q:a 4 -ar 44100 -ac 2 \ + -f ogg mycutscene.ogv + ``` +2. Write a `mycutscene.dcss` next to it describing the music cue points + (see [dcss-format.md](dcss-format.md)). +3. Point TFE at the directory holding them (`df_remasterCutscenesPath` + in `settings.ini`, or drop them in the Steam remaster's folder). +4. Add an entry to `cutscene.lst` so the game knows when to play it. + +That's it. No recompile, no plugins. + +## Quick start for players + +If you own the **Star Wars: Dark Forces Remaster** on Steam or GOG, TFE +will auto-detect it. Nothing to configure; the intro will play using the +remaster's HD video the next time you start Dark Forces. + +To turn it off, open TFE's settings UI (or `settings.ini`) and set +`df_enableRemasterCutscenes = false`. + +## When *not* to use this + +- Playing the original DOS Dark Forces? The LFD FILM path is still the + default and covers every cutscene. +- On a system without the remaster install and no modded content? + Nothing changes — the original LFD cutscenes play as before. + +The remaster OGV path is an **opt-in overlay**, not a replacement. + +## Source layout + +The code lives entirely under `TFE_DarkForces/`: + +``` +TFE_DarkForces/Landru/cutscene.cpp # Dispatch: which path to use, cue firing +TFE_DarkForces/Remaster/remasterCutscenes.* # Path detection, file resolution +TFE_DarkForces/Remaster/ogvPlayer.* # Ogg/Theora/Vorbis decode + YUV render +TFE_DarkForces/Remaster/dcssParser.* # .dcss script parser +TFE_DarkForces/Remaster/srtParser.* # .srt subtitle parser +``` + +Everything behind the `ENABLE_OGV_CUTSCENES` preprocessor flag. Builds +without the flag compile to the original LFD-only path exactly. + +## License note on your cutscene assets + +If you ship a mod containing remastered OGV files *from* the Dark Forces +Remaster, you are redistributing Disney/LucasArts content and that is +your problem to sort out with them. **Cutscenes you produce yourself +from scratch** (for a fan campaign, a new mission pack, etc.) belong to +you and you can ship them however you like — TFE has no claim. diff --git a/TheForceEngine/Documentation/markdown/remaster-cutscenes/architecture.md b/TheForceEngine/Documentation/markdown/remaster-cutscenes/architecture.md new file mode 100644 index 000000000..1ed7c5780 --- /dev/null +++ b/TheForceEngine/Documentation/markdown/remaster-cutscenes/architecture.md @@ -0,0 +1,251 @@ +# Architecture: how remastered cutscenes work in TFE + +This doc traces the pipeline from "game wants to play scene N" to +"pixels on screen + MIDI cues firing." It's aimed at anyone reading or +changing the code, and at modders who want to understand *why* the +format is shaped the way it is so they can push it further. + +## High-level flow + +``` + game code cutscene.lst movies/*.ogv + │ │ │ + │ cutscene_play(id) │ scene → {id, scene, nextId, │ + ▼ │ music, volume, speed} │ + +───────────────────────+ │ │ + │ cutscene.cpp │◀────┘ │ + │ - find scene by id │ │ + │ - try OGV path first │ │ + +───────────────────────+ │ + │ │ + ├─── found OGV for scene? ─── yes ──┐ │ + │ ▼ │ + │ +────────────────────+ │ + │ │ tryPlayOgvCutscene │ │ + │ │ - open OGV ────────┼──────┘ + │ │ - load DCSS script │ │ + │ │ - load SRT subs │◀──── cutscene_scripts/*.dcss + │ │ - reset iMuse │◀──── Subtitles/*.srt + │ +────────────────────+ + │ │ + │ ▼ + │ +────────────────────+ + │ │ ogvCutscene_update │ + │ │ per frame: │ + │ │ - decode frame │──▶ TFE_OgvPlayer + │ │ - dispatch cues │──▶ lmusic_setSequence/setCuePoint + │ │ - update caption │──▶ TFE_A11Y + │ +────────────────────+ + │ + └── no OGV, or feature off ── fall back to ──▶ cutscenePlayer (LFD FILM path, unchanged) +``` + +## Data sources + +The remaster doesn't invent a new catalog format. It keeps using the +original `CUTSCENE.LST` shipped inside `dark.gob`, then adds two +per-scene sidecar files. + +### 1. `cutscene.lst` — the scene catalog + +Lives inside `dark.gob`. One entry per scene, same format as the DOS +original: + +``` +: +``` + +Relevant fields for the remastered path: + +| Field | What it does in the OGV path | +|---|---| +| `id` | Which scene we're asked to play. | +| `scene` | **Base name for all remastered files**: `.ogv`, `.dcss`, `.srt`. | +| `next_id` | Not consumed by the OGV path (see "Chain behavior" below). | +| `music_seq` | **Fallback-only**: if no DCSS script is present, `lmusic_setSequence(music_seq)` fires once at playback start. | +| `volume` | Not consumed by the OGV path (DCSS's `musicvol:` directive takes over). | +| `speed`, `skip_id` | Ignored in the OGV path. | + +The full stock catalog is extracted at +[Appendix: stock cutscene.lst](#appendix-stock-cutscenelst). + +### 2. `.ogv` — the video + +Theora video + Vorbis audio in an Ogg container. See +[video-conversion.md](video-conversion.md) for encoding details. + +Locale variants are supported: `_.ogv` (e.g. +`logo_de.ogv`) is preferred over `.ogv` when the A11Y language +setting matches. The stock remaster only localizes `logo` because it +has baked-in credits text. + +### 3. `.dcss` — the timing script + +Small SRT-like text file that tells TFE when to change MIDI sequences, +fire cue points, and override music volume. Example (`arcfly.dcss`): + +``` +1 +00:00:00,327 +seq: 5 +cue: 1 + +2 +00:00:06,213 +cue: 2 + +3 +00:00:45,204 +cue: 3 +``` + +Each block is ` / / `. +Full reference in [dcss-format.md](dcss-format.md). + +### 4. `.srt` / `_.srt` — subtitles (optional) + +Standard SubRip format. Only shown when the player has +**"Closed captions for cutscenes"** enabled in TFE's accessibility +settings. + +## Path resolution + +`remasterCutscenes.cpp` finds the remaster's data directory at init +time. It tries, in order: + +1. **Custom path** from `df_remasterCutscenesPath` in `settings.ini`. + If set, must point at the `movies/` directory itself. +2. **Remaster docs path** (`PATH_REMASTER_DOCS`) if defined by the + platform. +3. **Source path** for Dark Forces (`sourcePath` in `settings.ini`'s + `[Dark_Forces]` section). Checks for a `movies/` or `Cutscenes/` + subdirectory. +4. **Windows Steam registry** (retail + TM editions) and GOG. +5. **TFE program directory**. + +Whichever wins, that path becomes `s_videoBasePath`. From there: + +- **`cutscene_scripts/`** is looked up first at the *parent* of the + video path (`/cutscene_scripts/`, matching how + `DarkEX.kpf` lays it out), then as a sibling of the videos. +- **`Subtitles/`** is looked up as a child of the video path, with a + fallback to loose `.srt` files alongside the videos. + +### File name resolution + +Given a `CutsceneState`, paths are built from **`scene->scene`** +(lowercased), not the archive name. This matches the remaster's own +behavior. The archive name (`ARCFLY.LFD` → `arcfly`) is a fallback for +edge cases where `scene` is empty. + +For a scene with `scene = "arcfly"` and the player's language = `"de"`: + +``` +OGV: movies/arcfly_de.ogv → fall back → movies/arcfly.ogv +DCSS: cutscene_scripts/arcfly.dcss +Subtitles: Subtitles/arcfly_de.srt → fall back → Subtitles/arcfly.de.srt + → fall back → Subtitles/arcfly.srt +``` + +## The cue dispatch loop + +Inside `ogvCutscene_update()` — called once per game frame while an +OGV is playing: + +1. Check for ESC/Enter/Space (outside Alt+Enter) → teardown and return. +2. `TFE_OgvPlayer::update()` — decodes packets, advances video time, + renders the current YUV frame as a fullscreen GPU quad. +3. `ogvCutscene_dispatchCues()` — walks forward through the sorted + DCSS entries, firing every one whose `timeMs` is ≤ the video's + intrinsic playback time: + - `seq` > 0 → `lmusic_setSequence(seq)` + - `cue` > 0 → `lmusic_setCuePoint(cue)` + - `musicVol` > 0 → scales MIDI volume by `vol / 100` +4. `ogvCutscene_updateCaptions()` — finds the active SRT entry for the + current time and hands it to TFE's caption system. + +### Why video time, not wall-clock time? + +`TFE_OgvPlayer` exposes two clocks: + +- `getPlaybackTime()` — seconds since `open()`, from the system timer. +- `getVideoTime()` — internal timeline advanced by `1/fps` per decoded, + presented frame. + +Cue dispatch uses `getVideoTime()`. If the game hitches — a stutter, +an asset load, GC, whatever — the wall-clock races ahead but the video +doesn't. Dispatching against wall-clock would fire music cues *before* +the frame they're meant to accompany. Using the intrinsic video clock +keeps the two locked. + +Measured drift on a full 1:53 `logo.ogv` playback: 0–33 ms (≤1 frame +at 30fps), no accumulation. + +## Chain behavior + +The LFD FILM path plays scene 10 → 20 → 30 → 40 → 41 internally via +`nextId` before returning control. The OGV path does **not** chain: +when one OGV ends, control returns to the game's outer loop. + +This matches what the remaster does in practice: each OGV is +self-contained and covers whatever the original LFD chain did visually. +For example, the remaster's `logo.ogv` (~1:53) contains the logo, Star +Wars crawl, text crawl, and closing frames all baked into a single +video, even though the LFD chain spans 5 separate scenes. + +**Implication for modders**: if your mod adds scenes 500 → 501 → 502 +all of which need cutscenes, either: + +- Bake them all into **one** OGV at scene 500 (and give 501/502 trivial + `nextId` paths that skip through quickly), or +- Ship three separate OGVs, one per scene, and let the game's outer + loop cycle through them the way it does for the remaster's scene + transitions. + +## Music integration + +The MIDI layer (`lmusic.{cpp,h}`) is shared across LFD and OGV paths. +It loads its sequence/cue catalog from `cutmuse.txt` (in `dark.gob`), +which the original DOS game used. The DCSS script's `seq:` and `cue:` +values are indices into those same tables. + +On OGV cutscene startup, `lmusic_setSequence(0)` is issued before the +first DCSS entry fires, to match the remaster's reset behavior. On +teardown, `lmusic_setSequence(0)` stops all audio. + +## What happens when it's all disabled + +`ENABLE_OGV_CUTSCENES` is a compile-time flag. When unset, the OGV code +is excluded entirely — the engine plays only the original LFD FILM +cutscenes. The flag is on by default in the Windows vcxproj; CMake +exposes it as an option behind `theora`/`ogg`/`vorbis` availability. + +## Appendix: stock `cutscene.lst` + +The remaster's `dark.gob` ships this file unmodified from the DOS +original. Non-trivial entries, annotated with which have OGVs in the +stock remaster: + +``` +# id archive scene speed next skip seq vol hasOGV? +10: logo.lfd logo 10 20 0 1 110 YES +20: swlogo.lfd swlogo 10 30 0 0 110 no (covered by logo.ogv) +30: ftextcra.lfd ftextcra 10 40 0 0 110 no (covered by logo.ogv) +40: 1e.lfd 1e 10 41 0 0 110 no +41: darklogo.lfd darklogo 7 0 0 0 110 no +200: kflyby.lfd kflyby 10 209 0 2 80 YES +500: gromas1.lfd gromas1 10 0 0 3 100 YES +550: gromasx.lfd gromasx 8 0 0 4 100 YES +600: arcfly.lfd arcfly 6 605 0 5 90 YES +800: rob1.lfd rob1 10 0 0 6 100 YES +850: robotx.lfd robotx 9 0 0 7 100 YES +1000: jabba1.lfd jabba1 10 1010 0 8 100 YES +1050: jabescp.lfd jabescp 10 0 0 9 100 YES +1400: cargo1.lfd cargo1 10 1410 0 10 100 YES +1450: exp1xx.lfd exp1xx 8 1451 0 11 110 YES +1500: fullcred.lfd fullcred 7 0 0 12 110 YES +``` + +Scenes 20, 30, 40, 41 have LFDs but no OGV — their visual content is +baked into `logo.ogv`. Same pattern holds for scenes like 209/210/…240 +(covered by `kflyby.ogv`). diff --git a/TheForceEngine/Documentation/markdown/remaster-cutscenes/dcss-format.md b/TheForceEngine/Documentation/markdown/remaster-cutscenes/dcss-format.md new file mode 100644 index 000000000..62ac6e6b5 --- /dev/null +++ b/TheForceEngine/Documentation/markdown/remaster-cutscenes/dcss-format.md @@ -0,0 +1,247 @@ +# DCSS format reference + +**DCSS** = Dark Cutscene Script. A tiny text format that tells TFE when +to change MIDI sequences, fire cue points, and override music volume +while an OGV cutscene plays. One `.dcss` file per cutscene, named to +match the OGV: `.dcss`. + +## Overall shape + +The file is a list of **entries**, separated by blank lines. Each +entry is three parts: + +``` + + + + +... +(blank line ends the entry) +``` + +Two optional **header flags** may appear before the first entry: +`+credits` and `+openingcredits`. + +Comments are supported on their own lines: `# comment` or `// comment`. +Everything else on a directive or timestamp line is parsed, so don't +put trailing comments after data. + +## Complete example + +``` ++credits + ++openingcredits + +# Opening logo — seq 1 plays the main title music. +1 +00:00:00,000 +seq: 1 +cue: 1 + +# Intro title card wipe. +2 +00:00:14,853 +cue: 2 + +3 +00:01:39,700 +cue: 4 + +# Crawl ends. +4 +00:01:50,487 +cue: 5 + +# Fade to dark logo. +5 +00:01:59,000 +cue: 7 +``` + +This is the stock remaster's `logo.dcss`, annotated. + +## Directives + +### `seq: N` + +Start (or switch to) MIDI sequence `N`. Sequences are defined in +`cutmuse.txt` (inside `dark.gob`) and are indexed 1..20. + +When a DCSS entry fires a `seq:` directive, TFE internally calls +`lmusic_setSequence(N)`, which unloads the current MIDI and loads the +new sequence's patch set. This is an expensive operation — use it when +the cutscene transitions between distinct musical pieces. + +Typical pattern: the first DCSS entry sets `seq:` to the scene's main +music sequence. Later entries leave `seq` unspecified and just fire +`cue:` values to transition within that sequence. + +### `cue: N` + +Trigger cue point `N` (1..20) within the currently loaded sequence. +`N = 0` is reserved for "stop all sounds" and is not used in the stock +data. + +Cue points handle intra-sequence transitions: moving from the intro +section to the main theme, crossfading tracks, fading out, etc. The +specific behavior depends on how the sequence was authored in +`cutmuse.txt`. + +### `musicvol: N` + +Set the MIDI music volume. `N` is a **percentage** where `100 = normal +volume**; values above 100 boost, below 100 attenuate. Practical range +is 0..127 (127 ≈ 27% louder than normal). + +Applied as: `scaled_volume = settings.cutsceneMusicVolume * (N / 100)`. +It persists until the next `musicvol:` directive or until the cutscene +ends (at which point TFE restores the user's base music volume). + +Stock examples: + +- `kflyby.dcss` entry 1: `musicvol: 80` (8% quieter during the arrival) +- `kflyby.dcss` entry 14: `musicvol: 90` (slight boost for the ending) +- `fullcred.dcss` entry 1: `musicvol: 110` (credits music is louder) + +## Header flags + +### `+credits` + +Signals that this cutscene should render TFE's credits overlay on top +of the video. Used by `logo.dcss` and `fullcred.dcss` in stock data. + +> **Implementation status**: TFE parses the flag but does not yet act +> on it. Reserved for future compatibility with the remaster's baked +> credits scroll. + +### `+openingcredits` + +Signals the "opening credits" variant. Only `logo.dcss` uses this. + +> **Implementation status**: parsed but not yet acted on. + +Both flags must appear **before** the first entry (i.e. before any +numbered block). Blank lines between them are fine. + +## Timestamp syntax + +The canonical form is SRT-style: `HH:MM:SS,mmm`. + +The parser is **deliberately tolerant** to match the remaster's own +implementation, which accepts several variants that appear in the +stock data as typos: + +| Variant | Example | Interpreted as | +|---|---|---| +| Canonical | `00:01:50,487` | 1m 50.487s | +| Period instead of comma | `00:01:50.487` | same | +| Colon instead of comma | `00:00:58:827` | 58.827s (yes, this is in `kflyby.dcss` as-shipped) | +| Short minute field | `00:1:50,487` | 1m 50.487s (yes, `logo.dcss` ships this) | + +Rules: + +- At least **three colon-separated numeric fields** are required + (HH:MM:SS). Two-field forms like `MM:SS` are rejected. +- Milliseconds are optional; no ms = 0. +- Leading zeros are optional on each field. +- The separator before milliseconds may be `,`, `:`, or `.`. +- At most 3 digits are consumed for the milliseconds field; extras are + silently dropped. + +## Entry index + +The number on the first line of each block is the **1-based entry +index**. It's informational and exists to mirror the SRT format; +parsers should see `1, 2, 3, …` in order. + +**Out-of-order indices log a warning** but the entry is still accepted. +**Entries are sorted by timestamp** on load, so even if you write them +out of order, dispatch will be correct. + +## Comments + +Lines whose first non-whitespace character is `#` or `//` are ignored. +They can appear: + +- At the top of the file, before any entry. +- Between entries (blank-line-delimited). +- Inside a directive block. + +Comments inline on a data line (e.g. `seq: 1 # main theme`) are **not** +supported — the comment marker is consumed as part of the value and +will break the parse. + +## What the parser does on invalid input + +| Condition | Behavior | +|---|---| +| Non-numeric where an index is expected | Log warning, skip this entry, resume at next blank line. | +| Unparseable timestamp | Log warning, skip this entry, resume. | +| Unknown directive line (`foo: 123`) | Silently ignored (forward-compat for future directives). | +| Truncated file (e.g. index line with no timestamp) | Return what was parsed so far. | +| Empty or missing file | Return `false` from `dcss_loadFromFile`; no entries, flags cleared. | + +Unknown directives being silently accepted is intentional — it lets a +newer version of TFE's DCSS parser add directives (say, +`subtitleColor:` or `hud:`) without breaking existing DCSS files on +older builds. + +## Minimum viable DCSS + +The smallest file that does anything useful has one entry with a +sequence and cue: + +``` +1 +00:00:00,000 +seq: 6 +cue: 1 +``` + +If you have no MIDI ambitions at all and just want the video to play +without music, you can skip the DCSS entirely. TFE's cutscene.cpp +falls back to `lmusic_setSequence(scene->music)` from `cutscene.lst` +and dispatches no cues. + +## Cue values reference + +The legal range for `seq:` is 1..20 and for `cue:` is 1..20. These are +indices into the sequence table in `cutmuse.txt` (packaged inside +`dark.gob`). TFE loads that catalog at startup and logs the mapping +when `SHOW_MUSIC_MSG` is enabled in `lmusic.cpp`. + +Stock sequence assignments (from the `cutscene.lst` `music_seq` +column): + +| Seq | Scene / purpose | +|---|---| +| 1 | Logo / opening crawl | +| 2 | Mission 1 (Talay / kflyby) | +| 3 | Gromas intro | +| 4 | Gromas exit | +| 5 | Mission 6 intro (arcfly) | +| 6 | Robotics intro (rob1) | +| 7 | Robotics exit (robotx) | +| 8 | Jabship intro (jabba1) | +| 9 | Jabship escape (jabescp) | +| 10 | Cargo (cargo1) | +| 11 | Finale intro (exp1xx) | +| 12 | Full credits | + +If you're adding a mod with new cutscenes, you can reuse these +sequences or extend `cutmuse.txt` via a mod GOB to add more (up to 20). + +## Differences from real SRT + +If you think of DCSS as "SRT with a different payload," these are the +places it diverges: + +| SRT | DCSS | +|---|---| +| `-->` on the timestamp line, with a start and end time | Only a single start timestamp | +| Payload is free-form text | Payload is `seq:` / `cue:` / `musicvol:` lines | +| No header flags | `+credits`, `+openingcredits` | + +Subtitles for TFE cutscenes use real SRT files, not DCSS. See +[modding-guide.md](modding-guide.md) for how those fit in. diff --git a/TheForceEngine/Documentation/markdown/remaster-cutscenes/modding-guide.md b/TheForceEngine/Documentation/markdown/remaster-cutscenes/modding-guide.md new file mode 100644 index 000000000..dd26b6dc8 --- /dev/null +++ b/TheForceEngine/Documentation/markdown/remaster-cutscenes/modding-guide.md @@ -0,0 +1,366 @@ +# Modder's guide: adding or replacing a cutscene + +This walks you end-to-end through adding a new cutscene to Dark +Forces running in TFE, or replacing an existing one. The process is: + +1. Convert your source video → `.ogv`. +2. Write a DCSS script for music cues. +3. Optionally write SRT subtitles. +4. Decide whether you're **replacing** a stock scene or **adding** a + new one. +5. Test. + +No recompile, no plugins. Everything lives in files TFE reads at +runtime. + +## Before you start + +You need: + +- A working TFE installation. +- **ffmpeg** with libtheora + libvorbis support (see + [video-conversion.md](video-conversion.md)). +- Your video source (MP4, MKV, whatever ffmpeg can read). +- A text editor. + +## Part 1: the video + +Convert to OGV: + +```sh +ffmpeg -i mycutscene.mp4 \ + -c:v libtheora -q:v 7 \ + -c:a libvorbis -q:a 4 -ar 44100 -ac 2 \ + -f ogg mycutscene.ogv +``` + +Tweak `-q:v` between 5 (smaller) and 9 (larger / higher quality). + +Verify it plays in a standalone player like VLC before moving on. + +## Part 2: the DCSS script + +Create `mycutscene.dcss` next to your video. The simplest possible +script just starts a MIDI sequence when the video opens: + +``` +1 +00:00:00,000 +seq: 1 +cue: 1 +``` + +This fires iMuse sequence 1 and cue point 1 at t=0. Both 1..20 map +into the sequence/cue tables defined by `cutmuse.txt` (packaged in +`dark.gob`). + +For music transitions during the cutscene, add more entries: + +``` +1 +00:00:00,000 +seq: 5 +cue: 1 + +2 +00:00:12,500 +cue: 2 + +3 +00:00:45,200 +cue: 3 +``` + +See [dcss-format.md](dcss-format.md) for the complete syntax reference, +including the `musicvol:` directive, comments, and timestamp +tolerances. + +## Part 3: subtitles (optional) + +TFE reads standard SubRip `.srt` files. If your cutscene has spoken +dialogue, ship an SRT so players with captions enabled can read along. + +Naming: + +| File | Used when | +|---|---| +| `mycutscene.srt` | Default / English — always tried last as a fallback. | +| `mycutscene_de.srt` | German (`language = de` in settings). | +| `mycutscene_fr.srt` | French. | +| `mycutscene_es.srt` | Spanish. | +| `mycutscene_it.srt` | Italian. | +| `mycutscene_.srt` | Any ISO-639-1 code. | + +Example content: + +``` +1 +00:00:00,500 --> 00:00:03,000 +Attack pattern delta! + +2 +00:00:03,500 --> 00:00:06,500 +The Empire will not stop us. +``` + +## Part 4: placement + +TFE looks for cutscene files in this order of preference: + +1. **Custom path** in `settings.ini` (`df_remasterCutscenesPath`). + Must point to a directory that contains or sits next to `movies/`. +2. **Remaster docs path** (platform-specific). +3. **Source data path** — your `sourcePath` for Dark Forces, with a + `movies/` subdirectory. +4. **Windows Steam/GOG registry** auto-detection. +5. **TFE program directory**. + +For modding, **use option 1 or option 3**. + +### Directory layout (recommended) + +``` +/ + movies/ + mycutscene.ogv + mycutscene_de.ogv (optional localized variant) + cutscene_scripts/ + mycutscene.dcss + Subtitles/ (or loose in movies/ — TFE checks both) + mycutscene.srt + mycutscene_de.srt +``` + +Then in TFE's `settings.ini`, set: + +```ini +[Dark_Forces] +df_enableRemasterCutscenes = true +df_remasterCutscenesPath = "C:/path/to/your_mod_dir/movies/" +``` + +> **Note**: `df_remasterCutscenesPath` points at the `movies/` +> directory itself, not its parent. TFE walks back up one level to +> find the sibling `cutscene_scripts/` directory. + +### Alternate layout (single-folder) + +If you want to keep everything in one directory: + +``` +/ + movies/ + mycutscene.ogv + mycutscene.srt + cutscene_scripts/ + mycutscene.dcss +``` + +TFE falls back to looking for `cutscene_scripts/` and subtitles +alongside the videos if it can't find them in the canonical location. + +## Part 5: wiring it into the game + +This is where "replacing" and "adding" diverge. + +### Replacing a stock cutscene + +Just name your files to match a stock scene name: + +| Stock scene | Your file names | +|---|---| +| `logo` (intro) | `logo.ogv`, `logo.dcss` | +| `arcfly` (level 6 intro) | `arcfly.ogv`, `arcfly.dcss` | +| `jabba1` (Jabba scene) | `jabba1.ogv`, `jabba1.dcss` | +| ...and so on | (see [architecture.md](architecture.md) for the full list) | + +TFE will pick up your files in place of the stock ones. No +`cutscene.lst` changes needed. + +### Adding a new cutscene + +You need to add an entry to `cutscene.lst`. In the original DOS game +and the remaster, `cutscene.lst` lives inside `dark.gob`. For TFE +modding, you override it by shipping a mod GOB. + +Write a fresh `cutscene.lst` (plain text) with your new entry: + +``` +CUT 1.0 + +CUTS 40 + +# ...existing stock entries unchanged... +10: logo.lfd logo 10 20 0 1 110 +20: swlogo.lfd swlogo 10 30 0 0 110 +# ... etc ... + +# your new entry — id 2000, scene "mycutscene": +2000: mycutscene.lfd mycutscene 10 0 0 13 100 +``` + +Field breakdown (space-separated): + +| Position | Value | Meaning | +|---|---|---| +| 1 | `2000:` | Scene ID, used by game code to request this cutscene. | +| 2 | `mycutscene.lfd` | Archive name. For OGV-only scenes with no LFD fallback, this can be a placeholder — it's only consulted if the OGV can't be found. | +| 3 | `mycutscene` | **Scene name — this is the base filename** for `.ogv` / `.dcss` / `.srt`. | +| 4 | `10` | Speed (fps of the LFD FILM; ignored for OGV path). | +| 5 | `0` | `nextId` — set to 0 for a single-cutscene chain, or to the ID of the next scene. Not used by the OGV path. | +| 6 | `0` | `skipId` — what ESC jumps to. | +| 7 | `13` | **Music sequence** (used if no DCSS script is found, as a fallback). | +| 8 | `100` | **Volume** as a percentage of base. | + +Pack this into a mod GOB (see TFE's existing mod GOB documentation). + +### Triggering a new cutscene + +The Dark Forces game code drives cutscene playback via scripted paths +(level transitions, mission completion, etc.). To trigger your new +scene ID `2000`, you need to hook it into one of these paths. Options: + +- **Level-end cutscene**: modify `s_cutsceneData` in `darkForcesMain.cpp` + — but this requires rebuilding TFE, so it's for engine contributors, + not runtime mods. +- **External data logic**: if TFE exposes cutscene triggering via JSON + mod data (check the latest project docs), declaratively reference + scene ID 2000 there. +- **Override an existing cutscene ID**: instead of adding 2000, reuse + an existing ID (say, 550 / `gromasx`) and let it play when the game + would normally trigger that scene. + +For most mods, **replacing** an existing scene is the pragmatic path. + +## Part 6: testing + +1. Launch TFE: `TheForceEngine.exe --game dark` +2. Open the log at + `%USERPROFILE%\Documents\TheForceEngine\the_force_engine_log.txt` + (or the OneDrive redirect if Documents is synced). +3. Trigger your cutscene — for the intro, just start a new game. + +### What a successful load looks like in the log + +``` +[Remaster] Using custom cutscene path: C:/.../your_mod_dir/movies/ +[Remaster] Found cutscene scripts at: C:/.../your_mod_dir/cutscene_scripts/ +[Remaster] Remaster OGV cutscene directory found. +[OgvPlayer] Opened OGV: 1280x720, 30.00 fps, with audio (rate=44100, channels=2) +[DcssParser] Loaded 3 cue entries from C:/.../cutscene_scripts/mycutscene.dcss (credits=0 openingCredits=0) +[Cutscene] Playing remastered OGV cutscene for scene 2000 ('mycutscene'). +``` + +### Verifying cue timing + +Flip `DCSS_TIMING_TRACE` from `0` to `1` near the top of +`cutscene.cpp` and rebuild TFE. With tracing on, every cue fire +produces a line like: + +``` +[DcssTiming] [mycutscene] cue #2 expected=10.000s video=10.000s wall=10.010s videoDrift=+0.000s wallDrift=+0.010s +``` + +Look for: + +- `videoDrift` ≤ one frame at your video's fps (e.g. ≤ 33 ms at 30fps). +- `cuesFired=N/N` at teardown — ideally all of them. +- `videoDuration` matches your source video's actual length. + +## Common "now what?" questions + +### "How do I find what iMuse sequences exist?" + +The sequences are defined in `cutmuse.txt` inside `dark.gob`. You can +extract it with TFE's archive tools or any LucasArts GOB reader. +Sequences 1–12 are used by stock cutscenes; sequences 13–20 are free +for mod use. + +### "Can I ship my own iMuse sequences?" + +Yes. Package a replacement `cutmuse.txt` in your mod GOB with entries +for your sequences. The format is straightforward; see +[architecture.md](architecture.md). + +### "Can I chain multiple OGVs for one cutscene?" + +Not directly in the current version of TFE. Each `cutscene_play()` call +runs one OGV to completion, then control returns to the outer game +flow. If you need a long cutscene, bake it into a single OGV. + +### "Can I ship locale-specific cutscenes without new OGVs?" + +Yes. The OGV is played regardless of locale; the SRT file is the +localized piece. Ship `mycutscene.srt` (English default) and +`mycutscene_de.srt`, `mycutscene_fr.srt`, etc. for other languages. + +### "How do I disable remastered cutscenes without deleting my mod?" + +In `settings.ini`: +```ini +[Dark_Forces] +df_enableRemasterCutscenes = false +``` +TFE will then play the original LFD FILM cutscene (if one exists for +that scene). + +## Full working example + +The end of this doc is a complete example of a minimal mod. + +**File tree:** + +``` +mymod/ + movies/ + intro.ogv (your converted video) + cutscene_scripts/ + intro.dcss (text below) + Subtitles/ + intro.srt (text below) + intro_de.srt +``` + +**`intro.dcss`:** + +``` +# My new intro. Uses iMuse sequence 1 (the logo theme). +1 +00:00:00,000 +seq: 1 +cue: 1 +musicvol: 100 + +# Bump volume for the action beat at 25s. +2 +00:00:25,000 +musicvol: 115 +cue: 2 +``` + +**`intro.srt`:** + +``` +1 +00:00:01,000 --> 00:00:04,000 +Mos Eisley. It begins. + +2 +00:00:25,500 --> 00:00:29,000 +Never tell me the odds. +``` + +**`settings.ini`** (TFE user config): + +```ini +[Dark_Forces] +sourcePath = "C:/path/to/your/dark/forces/install" +df_enableRemasterCutscenes = true +df_remasterCutscenesPath = "C:/path/to/mymod/movies/" +``` + +With the scene name `intro` and your `cutscene.lst` entry wiring it to +scene ID 10 (the intro), launching a new game will play your cutscene +with synced music. + +When you're stuck, start with [troubleshooting.md](troubleshooting.md). diff --git a/TheForceEngine/Documentation/markdown/remaster-cutscenes/troubleshooting.md b/TheForceEngine/Documentation/markdown/remaster-cutscenes/troubleshooting.md new file mode 100644 index 000000000..3597a0399 --- /dev/null +++ b/TheForceEngine/Documentation/markdown/remaster-cutscenes/troubleshooting.md @@ -0,0 +1,337 @@ +# Troubleshooting + +If a cutscene isn't behaving, work through the checks below in order. +Most issues are path, format, or timing mismatches; each has a distinct +signature in `the_force_engine_log.txt`. + +## Where is the log? + +``` +%USERPROFILE%\Documents\TheForceEngine\the_force_engine_log.txt +``` + +If your Documents folder is redirected to OneDrive, it's at +`%USERPROFILE%\OneDrive\Documents\TheForceEngine\…` instead. TFE logs +the resolved path at startup: + +``` +[Paths] User Documents: "C:\Users\you\OneDrive\Documents\TheForceEngine\" +``` + +Previous runs' logs are kept as `the_force_engine_log.txt.1`, +`.txt.2`, etc. + +## "The original LFD cutscene plays, not my OGV" + +### Check 1: is the remaster path detected? + +Look near the top of the log for one of: + +``` +[Remaster] Found remaster cutscenes at: /movies/ +[Remaster] Using custom cutscene path: /movies/ +[Remaster] Remaster OGV cutscene directory found. +``` + +If you see: + +``` +[Remaster] No remaster cutscene directory found; using original LFD cutscenes. +``` + +…TFE couldn't find the `movies/` directory. Fix in `settings.ini`: + +```ini +[Dark_Forces] +df_remasterCutscenesPath = "C:/path/to/movies/" +``` + +Note: point at `movies/`, **not** at `movies/` 's parent. + +### Check 2: is the feature toggle on? + +```ini +[Dark_Forces] +df_enableRemasterCutscenes = true +``` + +Absent from `settings.ini` → defaults to `true`. But UI interactions +might have written `false`. + +### Check 3: is there an OGV for this specific scene? + +For scene ``, TFE looks for: + +``` +/.ogv (or) +/_.ogv (if user's language is set) +``` + +If neither exists, the LFD path runs for that scene. Log will show +neither a `[Cutscene] Playing remastered…` nor a failure — it just +silently falls back. To confirm whether TFE tried, enable verbose +logging or check the file with `ls`: + +```sh +ls | grep -i +``` + +### Check 4: is the scene name correct? + +Per [architecture.md](architecture.md), file lookup uses the `scene` +field from `cutscene.lst`, not the archive name. For the stock intro, +`scene = "logo"` (lowercase), so `logo.ogv` is expected, not +`LOGO.OGV`. Windows filesystems are case-insensitive, but it's safer +to match the stock convention. + +## "TFE crashes when the cutscene plays" + +### If the log stops abruptly after `[Cutscene] Playing remastered OGV…` + +The OGV decoder hit something it can't handle. Re-check your source +file: + +```sh +ffprobe -v error -show_streams problem.ogv +``` + +Expected: +- One stream with `codec_name=theora` +- Optionally one with `codec_name=vorbis` + +If you see `codec_name=vp8` or `theoraX` or anything else, your ffmpeg +didn't actually encode Theora. See [video-conversion.md](video-conversion.md). + +### `[OgvPlayer] No Theora stream found in: ` + +Same cause. Re-encode. + +### `[OgvPlayer] Failed to read OGV headers from: ` + +File is truncated or corrupted. Try re-running ffmpeg. + +## "Music doesn't play / cues don't fire" + +### Check 1: is the DCSS being loaded? + +Expected log line: + +``` +[DcssParser] Loaded N cue entries from /.dcss +``` + +If missing, your DCSS file wasn't found. Check: + +``` +[Remaster] Found cutscene scripts at: /cutscene_scripts/ +``` + +The DCSS must be at that path as `.dcss`. If the log shows the +script path but your file is elsewhere, move it. + +### Check 2: DCSS fallback behavior + +If no DCSS is found but `cutscene.lst` lists a music sequence, TFE +falls back to playing just that sequence at t=0 with no cues: + +``` +[Cutscene] No DCSS script for scene 'myscene'; using cutscene.lst music=1 only. +``` + +This is fine for simple cases. Add a DCSS with `cue:` entries to +transition within the sequence. + +### Check 3: parse errors + +Each DCSS parse problem is logged as a warning: + +``` +[DcssParser] Expected numeric index, got 'seq: 5' +[DcssParser] Bad timestamp 'huh' at index 3 +[DcssParser] Out-of-order index 5 (expected 3) +``` + +Read [dcss-format.md](dcss-format.md) for the exact syntax. + +Common parse-breaking mistakes: + +| Mistake | Symptom | +|---|---| +| Blank line in the *middle* of a block (between timestamp and directives) | Entry ends after timestamp; no directives applied. | +| Trailing comment on a data line (`seq: 1 # main`) | Comment text captured as part of the value; `strtol` returns 1 as expected here but `musicvol: 80 # quiet` returns 80 too — it happens to work for numeric directives but *don't* rely on it. | +| Bare `seq:5` without space | Unrecognized directive, silently ignored. The parser specifically looks for `seq: ` (with the trailing space). | +| Using `MM:SS` (no hours) | Timestamp rejected; entry skipped. | + +### Check 4: sequence/cue numbers out of range + +`seq:` must be 1..20. `cue:` must be 1..20. Zero means "no change" +and anything outside the range is silently clamped. + +Stock sequence assignments are listed in +[dcss-format.md](dcss-format.md#cue-values-reference). + +## "Cues fire at the wrong times" + +### Turn on timing tracing + +In `cutscene.cpp` near the top, flip: + +```cpp +#define DCSS_TIMING_TRACE 1 +``` + +Rebuild. Each cue fire now logs: + +``` +[DcssTiming] [myscene] cue #2 expected=10.000s video=10.033s wall=10.041s videoDrift=+0.033s wallDrift=+0.041s +``` + +Interpret: + +| Column | Meaning | +|---|---| +| `expected` | DCSS timestamp. | +| `video` | Video's intrinsic time (decoded-frame clock). | +| `wall` | System time since playback started. | +| `videoDrift` | `video - expected`. Should be ≤ one frame at the OGV's fps. | +| `wallDrift` | `wall - expected`. Normally 5-20 ms more than `videoDrift`. | + +### What drift values mean + +- **videoDrift ≤ 1 frame**: Correct. The cue fired on the first frame + at or after the DCSS timestamp. This is the theoretical best. + +- **videoDrift negative**: Shouldn't happen. If it does, the video + clock went backwards — file a bug. + +- **videoDrift growing linearly with cue index**: Your DCSS + timestamps drift relative to the actual video. Re-check the video's + actual content timing in a standalone player and correct the DCSS. + +- **videoDrift stays small but wallDrift grows**: Game is hitching and + falling behind real-time. Not a TFE bug — look at why the game + loop is slow (CPU load, GPU contention, other cutscene code). + +- **`cuesFired=N/M` where N < M**: Some cues didn't fire. Usually + because they're past the OGV's natural end, as with stock + `logo.dcss` cue #5 at 1:59 in a 1:53 video. Either shorten the DCSS + or encode a longer OGV. + +## "Subtitles don't appear" + +### Check 1: captions enabled in settings? + +The captions have to be turned on in TFE's accessibility panel, or +set explicitly: + +```ini +[A11y] +cutsceneCaptionsEnabled = true +``` + +### Check 2: is the SRT found? + +Look for: + +``` +[SrtParser] Loaded N subtitle entries from /.srt +``` + +If missing, the SRT lookup failed. TFE tries, in order: + +1. `/_.srt` (the remaster convention) +2. `/..srt` (legacy / TFE back-compat) +3. `/.srt` (default) + +Where `` is your `language` setting in `[A11y]` (default `en`). + +If your Subtitles/ directory isn't being found, TFE also checks for +SRT files alongside the OGV itself as a fallback. + +### Check 3: SRT parse errors + +SRT timestamps use the same `HH:MM:SS,mmm` syntax as DCSS but +**require** both a start and an end separated by ` --> `: + +``` +1 +00:00:01,000 --> 00:00:04,000 +Subtitle text here. +``` + +If you see `[SrtParser] Cannot open SRT file:` — it's a path issue. If +the parse silently produces zero entries, the format is off. + +## "The video looks wrong (colors, stretching, black bars)" + +### Colors shifted (green/purple/overly saturated) + +YUV↔RGB conversion issue, usually from an unusual pixel format. Force +`yuv420p` on re-encode: + +```sh +ffmpeg -i in.mp4 -pix_fmt yuv420p -c:v libtheora -q:v 7 ... out.ogv +``` + +### Video stretched or squashed + +TFE letterboxes based on the OGV's `pic_width` / `pic_height` headers. +If those don't match the intended aspect ratio, re-encode with an +explicit scale: + +```sh +ffmpeg -i in.mp4 -vf "scale=1280:720,setsar=1" -c:v libtheora ... out.ogv +``` + +### Black bars too large + +Expected: TFE pillarboxes/letterboxes to preserve aspect ratio in any +window size. If the game is running at a 16:9 resolution but your OGV +is 4:3, you'll see side bars. Fix by either: + +- Re-encoding the OGV at 16:9 (crop or pad the source as appropriate) +- Setting the game window's aspect ratio to match the OGV + +## "Performance is bad during cutscenes" + +The OGV decoder is single-threaded. Theora at 1920×1080 and high +quality can briefly exceed one frame of work on slower CPUs, +particularly on keyframes. + +Try: + +- Reducing `-q:v` (6 or lower encodes with smaller motion-compensation + residuals, faster to decode). +- Reducing resolution (`-vf "scale=1280:720"`). +- Increasing GOP size (fewer keyframes): `-g 250` (default is around + 64 for libtheora). + +## "I changed settings.ini but TFE ignored them" + +Two files exist: + +- `%USERPROFILE%\Documents\TheForceEngine\settings.ini` +- `%USERPROFILE%\OneDrive\Documents\TheForceEngine\settings.ini` + (when Documents is OneDrive-redirected) + +TFE uses whichever resolves from `SHGetFolderPath(CSIDL_MYDOCUMENTS)`. +The log line `[Paths] User Documents: …` shows the one TFE actually +reads. Edit *that* file. + +TFE rewrites `settings.ini` on exit, so uncommitted edits made while +TFE is running get overwritten. Always edit with TFE closed, or use +TFE's in-game settings UI. + +## Still stuck? + +Attach: + +1. The full `the_force_engine_log.txt` from a run where the problem + occurred. +2. Your DCSS file. +3. Your `cutscene.lst` modifications (if any). +4. Your `settings.ini`. +5. Output of `ffprobe -v error -show_streams yourfile.ogv`. + +…to a GitHub issue at +[github.com/luciusDXL/TheForceEngine](https://github.com/luciusDXL/TheForceEngine). diff --git a/TheForceEngine/Documentation/markdown/remaster-cutscenes/video-conversion.md b/TheForceEngine/Documentation/markdown/remaster-cutscenes/video-conversion.md new file mode 100644 index 000000000..efca487a5 --- /dev/null +++ b/TheForceEngine/Documentation/markdown/remaster-cutscenes/video-conversion.md @@ -0,0 +1,265 @@ +# Converting video to TFE's OGV format + +TFE uses the **Ogg container** with **Theora** video and **Vorbis** +audio. This is the same combination the Dark Forces Remaster ships, +and it's what TFE's `ogvPlayer` decodes. + +This guide shows a verified command line for converting MP4 (or +anything ffmpeg can read) into a `.ogv` that TFE plays correctly. + +## Prerequisites + +- **ffmpeg** built with `--enable-libtheora --enable-libvorbis`. + The standard Windows release builds from + [ffmpeg.org](https://ffmpeg.org/download.html) include both. + Verify with: + ```sh + ffmpeg -encoders 2>&1 | grep -iE "libtheora|libvorbis" + ``` + You should see both listed. + +Nothing else. No Theora-specific tools required; ffmpeg handles it. + +## The one-liner + +```sh +ffmpeg -i input.mp4 \ + -c:v libtheora -q:v 7 \ + -c:a libvorbis -q:a 4 -ar 44100 -ac 2 \ + -f ogg output.ogv +``` + +### What each flag does + +| Flag | Purpose | +|---|---| +| `-c:v libtheora` | Video codec: Theora (what TFE decodes). | +| `-q:v 7` | Video quality 0–10 (higher = better, larger). 7 is a good default. The stock remaster targets similar quality. | +| `-c:a libvorbis` | Audio codec: Vorbis. | +| `-q:a 4` | Audio quality 0–10 (higher = better). 4 ≈ 128 kbps stereo. | +| `-ar 44100` | Resample audio to 44.1 kHz (what TFE's mixer targets). | +| `-ac 2` | Force stereo output. Mono sources get upmixed; 5.1 gets downmixed. | +| `-f ogg` | Force Ogg container. Not strictly needed since the `.ogv` extension implies it, but explicit is safer. | + +## Verified result + +This exact command was tested by converting a 30-second 640×400 MPEG-4 +test clip to OGV and playing it through TFE with a hand-written DCSS: + +``` +[OgvPlayer] Opened OGV: 640x400, 20.00 fps, with audio (rate=44100, channels=2) +[DcssParser] Loaded 3 cue entries from test.dcss +[Cutscene] Playing remastered OGV cutscene for scene 10 ('logo'). +[DcssTiming] cue #1 expected=0.000s video=0.000s videoDrift=+0.000s +[DcssTiming] cue #2 expected=10.000s video=10.000s videoDrift=+0.000s +[DcssTiming] cue #3 expected=20.000s video=20.000s videoDrift=+0.000s +[DcssTiming] END videoDuration=29.950s cuesFired=3/3 +``` + +All three cues fired with zero drift against the DCSS timestamps. + +## Choosing quality and file size + +Theora's quality scale is non-linear. Rough rule of thumb: + +| `-q:v` | Use case | Bitrate at 640×400 20fps | +|---|---|---| +| 4 | Small preview / low-priority content | ~300 kbps | +| 7 | **Recommended** for game cutscenes | ~700 kbps | +| 9 | Near-lossless, for pristine masters | ~2 Mbps | +| 10 | Use if source is critical; big files | ~4+ Mbps | + +For reference, the stock remaster `logo.ogv` is ~180 MB for 1:53, which +is ~13 Mbps — suggesting they used a very high quality setting (8-10) +on a high-resolution source (the video is 1280×800 or similar). + +Two-pass encoding is available if you need to hit a specific bitrate +budget: + +```sh +# Pass 1 +ffmpeg -y -i input.mp4 -c:v libtheora -b:v 1500k -pass 1 -an -f ogg /dev/null +# Pass 2 +ffmpeg -i input.mp4 \ + -c:v libtheora -b:v 1500k -pass 2 \ + -c:a libvorbis -q:a 4 -ar 44100 -ac 2 \ + output.ogv +``` + +## Aspect ratio and resolution + +TFE's `ogvPlayer` **letterboxes** the video into whatever window +resolution the game is running at, preserving the video's aspect ratio. +You do not need to encode at 320×200. + +**Recommendations:** + +- Keep the **source aspect ratio**. If your source is 16:9, encode at + 16:9; TFE will pillarbox if the game window is narrower. +- **Even-numbered dimensions.** Theora requires both width and height + to be multiples of 16 for best quality (2 at minimum). If your + source is odd, ffmpeg will silently pad or crop. +- **Scale if the source is huge.** 1920×1080 decodes fine on modern + hardware, but the file size balloons. 1280×720 is a good sweet spot + for fan content. + +Forcing a specific target resolution: + +```sh +ffmpeg -i input.mp4 \ + -vf "scale=1280:720" \ + -c:v libtheora -q:v 7 \ + -c:a libvorbis -q:a 4 -ar 44100 -ac 2 \ + -f ogg output.ogv +``` + +## Framerate + +TFE plays OGVs at whatever framerate they're encoded at. The decoder +respects the file's `fps_numerator/denominator` header and the +dispatcher locks cue timing to decoded frames. + +**Don't force 60fps** on a source that's natively 24 or 30 — you'll +triple the file size for no visible benefit and the cue dispatch will +still only resolve to frame boundaries of whatever the actual fps is. + +If you *must* change the source framerate: + +```sh +ffmpeg -i input.mp4 -vf "fps=30" ... +``` + +## Audio + +### Mixing + +TFE's audio system mixes OGV audio in at the **`cutsceneSoundFxVolume +* masterVolume`** level at the same time the MIDI music plays at +**`cutsceneMusicVolume * masterVolume * `**. Both volume +sliders are in TFE's settings UI. + +If your cutscene has dialogue, leave the music bed *quiet* in the OGV +audio (or absent entirely) and let the DCSS-dispatched MIDI be the +music. That's what the remaster does. + +### Format + +Vorbis can go up to 48 kHz / 8 channels, but TFE's player: + +- Resamples to 44.1 kHz internally. +- Downmixes to stereo. + +Encoding directly to 44.1 kHz stereo saves the decoder some work and +avoids subtle resample artifacts. + +### Silent cutscenes + +Some of the stock `exp1xx`, `gromasx`, etc. files effectively have no +audio — the MIDI score provided by DCSS is all. If your cutscene is +music-only, pass `-an` to skip audio entirely: + +```sh +ffmpeg -i input.mp4 -c:v libtheora -q:v 7 -an -f ogg output.ogv +``` + +TFE's player handles no-audio OGVs correctly. + +## Batch conversion + +If you've got a stack of MP4s to convert: + +```sh +for f in *.mp4; do + ffmpeg -i "$f" \ + -c:v libtheora -q:v 7 \ + -c:a libvorbis -q:a 4 -ar 44100 -ac 2 \ + -f ogg "${f%.mp4}.ogv" +done +``` + +(Bash; Windows `cmd` equivalent uses `for %f in (*.mp4) do …`.) + +## Checking your result + +### File-level sanity + +```sh +ffprobe -v error -show_streams output.ogv +``` + +You should see one `codec_name=theora` video stream and (optionally) +one `codec_name=vorbis` audio stream. + +### Running it through TFE + +1. Drop your `output.ogv` into `/movies/` (or your + custom cutscene directory). +2. Write a minimal DCSS at + `/cutscene_scripts/.ogv`: + ``` + 1 + 00:00:00,000 + seq: 1 + cue: 1 + ``` +3. Add or modify an entry in `cutscene.lst` to point `scene` at your + file (see [modding-guide.md](modding-guide.md)). +4. Start TFE with `--game dark`, trigger the cutscene, and watch + `~/Documents/TheForceEngine/the_force_engine_log.txt` for: + ``` + [OgvPlayer] Opened OGV: WxH, FPS fps, with audio (rate=..., channels=...) + [Cutscene] Playing remastered OGV cutscene for scene N (''). + ``` + +### Diagnosing drift or sync issues + +Edit `cutscene.cpp` and flip `DCSS_TIMING_TRACE` from `0` to `1`, +rebuild, and re-run. The log will show per-cue drift in seconds: + +``` +[DcssTiming] [myscene] cue #2 expected=10.000s video=10.000s wall=10.010s videoDrift=+0.000s wallDrift=+0.010s +``` + +`videoDrift` should be ≤ one frame. If it's much larger, something +is wrong — usually a hand-written DCSS timestamp that doesn't actually +exist in the video. See [troubleshooting.md](troubleshooting.md). + +## Common mistakes + +**"My OGV looks purple/green/corrupted."** +Theora only supports `yuv420p` / `yuv422p` / `yuv444p` pixel formats. +If ffmpeg got a different input format, it should convert +automatically, but passing `-pix_fmt yuv420p` explicitly is safe: + +```sh +ffmpeg -i input.mp4 -pix_fmt yuv420p -c:v libtheora -q:v 7 ... output.ogv +``` + +**"Audio is garbled/slow/fast."** +Check that you passed `-ar 44100`. Without it, ffmpeg keeps the +source's sample rate (say 48000 Hz) and TFE's resampler has to do more +work, which in rare cases produces pitch drift. + +**"TFE says `No Theora stream found`."** +Your output file probably isn't actually Theora. Some ffmpeg builds +fall back silently to another codec when `libtheora` isn't compiled +in. Re-check `ffmpeg -encoders | grep theora` — you need the one with +`V..... libtheora` (uppercase V means video encoder). + +**"My video plays but at the wrong resolution / stretched."** +TFE letterboxes to preserve aspect ratio based on the OGV's +`pic_width` / `pic_height` headers. If those got set incorrectly +during encoding (rare, but happens with cropped inputs), re-encode +with `-vf "scale=W:H,setsar=1"` to normalize. + +**"Huge file sizes."** +Drop `-q:v` from 7 to 5 for a 30-40% size reduction with barely +noticeable quality loss on typical game cutscene content. Or move to +two-pass bitrate encoding. + +## What about other codecs? + +TFE only reads **Theora** video and **Vorbis** audio, in an **Ogg** +container. MP4/H.264, MKV/AV1, WebM/VP9 — all unreadable by TFE's +player. No plans to add other codecs: Theora is sufficient, fully +free/open (no patent licensing), and matches what the remaster ships. diff --git a/TheForceEngine/Shaders/yuv2rgb.frag b/TheForceEngine/Shaders/yuv2rgb.frag new file mode 100644 index 000000000..776e62799 --- /dev/null +++ b/TheForceEngine/Shaders/yuv2rgb.frag @@ -0,0 +1,24 @@ +// BT.601 YCbCr -> RGB for Theora video frames. +uniform sampler2D TexY; +uniform sampler2D TexCb; +uniform sampler2D TexCr; + +in vec2 Frag_UV; +out vec4 Out_Color; + +void main() +{ + float y = texture(TexY, Frag_UV).r; + float cb = texture(TexCb, Frag_UV).r - 0.5; + float cr = texture(TexCr, Frag_UV).r - 0.5; + + // Rescale Y from studio range [16..235] to [0..1]. + y = (y - 16.0 / 255.0) * (255.0 / 219.0); + + vec3 rgb; + rgb.r = y + 1.596 * cr; + rgb.g = y - 0.391 * cb - 0.813 * cr; + rgb.b = y + 2.018 * cb; + + Out_Color = vec4(clamp(rgb, 0.0, 1.0), 1.0); +} diff --git a/TheForceEngine/Shaders/yuv2rgb.vert b/TheForceEngine/Shaders/yuv2rgb.vert new file mode 100644 index 000000000..fcb94c954 --- /dev/null +++ b/TheForceEngine/Shaders/yuv2rgb.vert @@ -0,0 +1,11 @@ +uniform vec4 ScaleOffset; +in vec2 vtx_pos; +in vec2 vtx_uv; + +out vec2 Frag_UV; + +void main() +{ + Frag_UV = vec2(vtx_uv.x, 1.0 - vtx_uv.y); + gl_Position = vec4(vtx_pos.xy * ScaleOffset.xy + ScaleOffset.zw, 0, 1); +} diff --git a/TheForceEngine/TFE_Audio/audioSystem.cpp b/TheForceEngine/TFE_Audio/audioSystem.cpp index cb16040c4..1a31c8895 100644 --- a/TheForceEngine/TFE_Audio/audioSystem.cpp +++ b/TheForceEngine/TFE_Audio/audioSystem.cpp @@ -74,6 +74,7 @@ namespace TFE_Audio static AudioUpsampleFilter s_upsampleFilter = AUF_DEFAULT; static AudioThreadCallback s_audioThreadCallback = nullptr; + static AudioDirectCallback s_directCallback = nullptr; static void audioCallback(void*, unsigned char*, int); void setSoundVolumeConsole(const ConsoleArgList& args); @@ -227,6 +228,15 @@ namespace TFE_Audio SDL_UnlockMutex(s_mutex); } + void setDirectCallback(AudioDirectCallback callback) + { + if (s_nullDevice) { return; } + + SDL_LockMutex(s_mutex); + s_directCallback = callback; + SDL_UnlockMutex(s_mutex); + } + const OutputDeviceInfo* getOutputDeviceList(s32& count, s32& curOutput) { return TFE_AudioDevice::getOutputDeviceList(count, curOutput); @@ -467,9 +477,9 @@ namespace TFE_Audio // First clear samples memset(buffer, 0, bufferSize); - + SDL_LockMutex(s_mutex); - // Then call the audio thread callback + // Call the audio thread callback (iMuse/game audio). if (s_audioThreadCallback && !s_paused) { static f32 callbackBuffer[(AUDIO_CALLBACK_BUFFER_SIZE + 2)*AUDIO_CHANNEL_COUNT]; // 256 stereo + oversampling. @@ -488,6 +498,11 @@ namespace TFE_Audio } } } + // Direct callback adds at the full output rate (e.g. OGV video audio), mixing on top. + if (s_directCallback && !s_paused) + { + s_directCallback(buffer, frames, s_soundFxVolume * c_soundHeadroom); + } // Then loop through the sources. // Note: this is no longer used by Dark Forces. However I decided to keep direct sound support around diff --git a/TheForceEngine/TFE_Audio/audioSystem.h b/TheForceEngine/TFE_Audio/audioSystem.h index 0202b0fc7..2c55eee3f 100644 --- a/TheForceEngine/TFE_Audio/audioSystem.h +++ b/TheForceEngine/TFE_Audio/audioSystem.h @@ -47,6 +47,9 @@ enum SoundType typedef void(*SoundFinishedCallback)(void* userData, s32 arg); typedef void(*AudioThreadCallback)(f32* buffer, u32 bufferSize, f32 systemVolume); +// Direct callback writes stereo interleaved f32 at the full output rate (44100 Hz). +// frameCount = number of stereo frames to fill. +typedef void(*AudioDirectCallback)(f32* buffer, u32 frameCount, f32 systemVolume); namespace TFE_Audio { @@ -76,6 +79,7 @@ namespace TFE_Audio void bufferedAudioClear(); void setAudioThreadCallback(AudioThreadCallback callback = nullptr); + void setDirectCallback(AudioDirectCallback callback = nullptr); const OutputDeviceInfo* getOutputDeviceList(s32& count, s32& curOutput); // One shot, play and forget. Only do this if the client needs no control until stopAllSounds() is called. diff --git a/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp b/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp index 25e5ac572..cb0a4654a 100644 --- a/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp +++ b/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp @@ -1,3 +1,39 @@ +//============================================================================ +// Dark Forces cutscene dispatch +//============================================================================ +// +// Every cutscene request funnels through here. For each scene id we get +// asked to play, we pick one of two rendering paths: +// +// 1. The remastered OGV path — if the feature is enabled, the remaster +// data is available, and an .ogv exists for this scene. Plays +// the Theora video with Vorbis audio mixed in, and dispatches MIDI +// cue points from a DCSS text script against the video's clock. +// +// 2. The original LFD FILM path (cutscenePlayer_*) — the legacy Landru- +// based cutscene player. Unchanged from stock TFE; this is what DOS +// Dark Forces played. +// +// The OGV path lives in this file; the LFD path lives in cutscene_player.*. +// Both funnel into lmusic_setSequence / lmusic_setCuePoint for MIDI, so the +// music layer is shared. +// +// On cutscene.cpp's design choices: +// +// - We prefer OGV when available. The remaster's cutscenes are the +// authoritative version (higher fidelity, properly timed, subtitled). +// Falling back to LFD is a graceful degradation, not a user choice. +// +// - Cue dispatch uses the video's *intrinsic* clock (getVideoTime), not +// wall-clock. If the game hitches, wall-clock races ahead of the +// visible frame; dispatching against wall-clock would make music cues +// fire before the visual moment they're meant to accompany. See +// DESIGN NOTE #1 near ogvCutscene_dispatchCues() for the numbers. +// +// - DCSS is optional. If a scene has an OGV but no DCSS, we fall back to +// the scene's cutscene.lst music_seq so there's still *some* MIDI. +// Modders get the easy path without having to author a DCSS. +// #include "cutscene.h" #include "cutscene_player.h" #include "lsystem.h" @@ -11,61 +47,480 @@ #include #include #include +#ifdef ENABLE_OGV_CUTSCENES +#include +#include +#include +#include +#include +#include +#include "lmusic.h" +#endif using namespace TFE_Jedi; namespace TFE_DarkForces { + // ---------------------------------------------------------------------- + // Shared state (both paths) + // ---------------------------------------------------------------------- + + // True while *either* path has an active cutscene. Drives the dispatch + // in cutscene_update(). static JBool s_playing = JFALSE; + // The scene catalog from cutscene.lst. Non-owning; loaded at Dark Forces + // game init and handed to us via cutscene_init. CutsceneState* s_playSeq = nullptr; + + // These four are preserved from the original code. s_enabled lets higher + // layers disable cutscenes entirely (e.g. during demo playback), while + // the volume globals are deprecated shadows of the settings system. s32 s_soundVolume = 0; s32 s_musicVolume = 0; s32 s_enabled = 1; +#ifdef ENABLE_OGV_CUTSCENES + // ---------------------------------------------------------------------- + // OGV-path state + // ---------------------------------------------------------------------- + + // True while an OGV is actively playing. When this is true, cutscene_ + // update() dispatches to ogvCutscene_update() instead of the LFD player. + static bool s_ogvPlaying = false; + + // Parsed subtitles for the current cutscene. Empty if the user has + // captions off, or if no SRT was found for this scene. + static std::vector s_ogvSubtitles; + + // DCSS cue script for the current cutscene. Entries are sorted by + // timeMs; s_ogvNextCueIdx is the index of the next-to-fire entry. + // Dispatch walks forward only; we never rewind. + static DcssScript s_ogvScript; + static size_t s_ogvNextCueIdx = 0; + + // Timing-test instrumentation. Flip this to 1 in a local build when + // authoring a new DCSS or diagnosing a sync issue: every cue fire logs + // expected-vs-actual timestamps, and teardown logs the total video + // duration. Off in production to keep the default log quiet. + #define DCSS_TIMING_TRACE 0 + #if DCSS_TIMING_TRACE + static f64 s_ogvStartWallTime = 0.0; + static f64 s_ogvLastVideoTime = 0.0; + static const char* s_ogvTraceSceneName = ""; + #endif +#endif + + // ---------------------------------------------------------------------- + // Initialization + // ---------------------------------------------------------------------- + + // Called by darkForcesMain at game boot, once cutscene.lst has been + // loaded. This is also where we kick off the remaster path detection + // so the subsequent cutscene_play() calls don't have to lazy-probe. void cutscene_init(CutsceneState* cutsceneList) { s_playSeq = cutsceneList; s_playing = JFALSE; +#ifdef ENABLE_OGV_CUTSCENES + remasterCutscenes_init(); +#endif } +#ifdef ENABLE_OGV_CUTSCENES + // ====================================================================== + // OGV path helpers + // ====================================================================== + + // Linear scan for a scene by id. The list is short (<50 entries in + // stock data) and cutscene playback is infrequent, so we skip building + // a hash table and just walk the array. SCENE_EXIT (0) is the + // sentinel terminator. + static CutsceneState* findScene(s32 sceneId) + { + if (!s_playSeq) { return nullptr; } + for (s32 i = 0; s_playSeq[i].id != SCENE_EXIT; i++) + { + if (s_playSeq[i].id == sceneId) { return &s_playSeq[i]; } + } + return nullptr; + } + + // Apply a DCSS "musicvol: N" override to the MIDI player. + // + // CUTSCENE.LST's header says the volume field is "110 = 10% higher + // than normal," so 100 is the unity point. Matches what the LFD path + // does at cutscene_player.cpp:151 (vol/100). Earlier versions of this + // code incorrectly divided by 127, which made every cutscene 20% + // quieter than intended. + // + // 127 is a soft practical ceiling - iMuse's internal MIDI volume + // scale is 0..127, and going above that doesn't get you anything. + static void ogvCutscene_applyMusicVolume(s32 volPercent) + { + const TFE_Settings_Sound* soundSettings = TFE_Settings::getSoundSettings(); + f32 scalar = (f32)clamp(volPercent, 0, 127) / 100.0f; + TFE_MidiPlayer::setVolume(soundSettings->cutsceneMusicVolume * soundSettings->masterVolume * scalar); + } + + // Release all per-cutscene resources and reset OGV state. Called when + // the user skips (ESC/Enter/Space), when the OGV decoder reports end- + // of-stream, or when playback fails. + // + // Order matters: we stop the MIDI *before* restoring volume so the + // fade-out doesn't audibly clip. The OgvPlayer::close() is also safe + // to call even if the player already closed itself internally (it's + // idempotent). + static void ogvCutscene_teardown() + { + #if DCSS_TIMING_TRACE + { + f64 wallSec = TFE_System::getTime() - s_ogvStartWallTime; + // getVideoTime() returns 0 once the player has closed, so + // capture the last value we saw during dispatch instead. This + // gives us an accurate duration figure for the END log. + f64 videoSec = s_ogvLastVideoTime; + size_t fired = s_ogvNextCueIdx; + size_t total = s_ogvScript.entries.size(); + TFE_System::logWrite(LOG_MSG, "DcssTiming", + "[%s] END videoDuration~=%.3fs wallDuration=%.3fs cuesFired=%zu/%zu", + s_ogvTraceSceneName, videoSec, wallSec, fired, total); + } + #endif + TFE_OgvPlayer::close(); + s_ogvPlaying = false; + + // Clear cutscene-scoped parse data. No memory to free directly; + // std::vector/string destructors handle it. + s_ogvSubtitles.clear(); + s_ogvScript.entries.clear(); + s_ogvScript.creditsFlag = false; + s_ogvScript.openingCreditsFlag = false; + s_ogvNextCueIdx = 0; + TFE_A11Y::clearActiveCaptions(); + + // Match the remaster's teardown: setSequence(0) unloads all MIDI + // state and stops any in-flight notes. + lmusic_setSequence(0); + + // If a DCSS entry set a musicvol override during playback, the + // next cutscene (or ambient game music) would inherit it. Reset + // to the user's configured base so future MIDI plays at the + // right level. + const TFE_Settings_Sound* soundSettings = TFE_Settings::getSoundSettings(); + TFE_MidiPlayer::setVolume(soundSettings->cutsceneMusicVolume * soundSettings->masterVolume); + } + + // ---------------------------------------------------------------------- + // OGV path - scene startup + // ---------------------------------------------------------------------- + // + // Decide whether this scene has an OGV we can play. Returns true on + // success (caller should set s_playing=true); false means "no dice, + // fall back to LFD." Not actually playing the video yet - that happens + // in ogvCutscene_update() frame-by-frame. + // + // Every bail-out here is silent-false (no log) except when we + // specifically opened a file and it failed partway - those warrant a + // warning because the user might be debugging a bad OGV. + // + static bool tryPlayOgvCutscene(s32 sceneId) + { + // Two opt-outs: the feature toggle (user pref) and the "we found + // no remaster install" detection result. + TFE_Settings_Game* gameSettings = TFE_Settings::getGameSettings(); + if (!gameSettings->df_enableRemasterCutscenes) { return false; } + if (!remasterCutscenes_available()) { return false; } + + CutsceneState* scene = findScene(sceneId); + if (!scene) { return false; } + + // OGV lookup handles locale variants (e.g. logo_de.ogv). Returns + // nullptr if neither variant exists - that's a legitimate "not + // all scenes have OGVs" case; the LFD path handles it. + const char* videoPath = remasterCutscenes_getVideoPath(scene); + if (!videoPath) { return false; } + + if (!TFE_OgvPlayer::open(videoPath)) + { + // File exists but the Theora decoder rejected it. Could be a + // corrupted OGV or an unusual codec config. Don't crash - the + // LFD path is still a viable fallback. + TFE_System::logWrite(LOG_WARNING, "Cutscene", "Failed to open OGV file: %s, falling back to LFD.", videoPath); + return false; + } + + // Subtitles are best-effort: if captions are off or the SRT is + // missing, we silently play without them. + s_ogvSubtitles.clear(); + if (TFE_A11Y::cutsceneCaptionsEnabled()) + { + const char* srtPath = remasterCutscenes_getSubtitlePath(scene); + if (srtPath) { srt_loadFromFile(srtPath, s_ogvSubtitles); } + } + + // Reset DCSS state before loading a fresh one. + s_ogvScript.entries.clear(); + s_ogvScript.creditsFlag = false; + s_ogvScript.openingCreditsFlag = false; + s_ogvNextCueIdx = 0; + + // The DCSS drives every music cue for this scene. Two paths: + // + // a) DCSS found + parsed: reset MIDI to a clean state so the + // first DCSS entry's "seq: N" actually causes a sequence + // change (lmusic_setSequence is a no-op if asked to set the + // current sequence again). + // + // b) No DCSS (modder didn't write one, or a test): fall back + // to the scene's cutscene.lst music_seq. This gives modders + // a zero-effort path - just ship an OGV and the original + // MIDI sequence keeps playing. + // + const char* dcssPath = remasterCutscenes_getDcssPath(scene); + if (dcssPath && dcss_loadFromFile(dcssPath, s_ogvScript)) + { + lmusic_setSequence(0); + } + else if (scene->music > 0) + { + TFE_System::logWrite(LOG_MSG, "Cutscene", + "No DCSS script for scene '%s'; using cutscene.lst music=%d only.", + scene->scene, (s32)scene->music); + lmusic_setSequence(scene->music); + } + + s_ogvPlaying = true; + #if DCSS_TIMING_TRACE + s_ogvStartWallTime = TFE_System::getTime(); + s_ogvTraceSceneName = scene->scene; + TFE_System::logWrite(LOG_MSG, "DcssTiming", + "[%s] START scene=%d entries=%zu", s_ogvTraceSceneName, sceneId, s_ogvScript.entries.size()); + #endif + TFE_System::logWrite(LOG_MSG, "Cutscene", "Playing remastered OGV cutscene for scene %d ('%s').", + sceneId, scene->scene); + return true; + } + + // ---------------------------------------------------------------------- + // OGV path - per-frame dispatch + // ---------------------------------------------------------------------- + // + // DESIGN NOTE #1 — Why we use video time, not wall-clock: + // + // TFE_OgvPlayer exposes two clocks. getPlaybackTime() is wall-clock + // since open(). getVideoTime() is the *intrinsic* video clock that + // advances by 1/fps per decoded, presented frame. + // + // If the game loop hitches (asset load, GC, stutter, whatever), + // wall-clock keeps running but the visible frame doesn't. Dispatching + // music cues off wall-clock would fire them ahead of the image they + // accompany - subtle but noticeable, and really ugly when the hitch + // is near a cue point. + // + // Using video time, cues stay locked to the frame. Measured drift + // over a full 1:53 logo.ogv playback: 0–33 ms (≤ one frame at 30fps), + // never growing. + // + // DESIGN NOTE #2 — Firing rules per DCSS entry: + // + // Each entry fields each have a "don't change" sentinel (0 or + // non-positive). We fire the directive only if its value is + // non-default. This matches the remaster's dispatch (decompiled from + // khonsu around offset 262555 - "if (v36) setSequence(v36);"). + // + // Critically, leaving seq=0 means "the sequence keeps playing." Most + // stock DCSS files use this pattern: entry #1 sets seq, entries #2+ + // only set cue. That way we don't reload the MIDI mid-cutscene. + // + static void ogvCutscene_dispatchCues() + { + if (s_ogvScript.entries.empty()) { return; } + + // Intrinsic video clock. Converted to whole milliseconds for + // comparison against DCSS's u64 timestamps (which are in ms too). + const f64 videoTimeSec = TFE_OgvPlayer::getVideoTime(); + #if DCSS_TIMING_TRACE + // Capture the last non-zero video time for the teardown log. + // (getVideoTime returns 0 once the player closes, which would + // make our "END videoDuration" log report 0 otherwise.) + if (videoTimeSec > 0.0) { s_ogvLastVideoTime = videoTimeSec; } + #endif + const u64 nowMs = (u64)(videoTimeSec * 1000.0); + + // Walk forward through the sorted cue list, firing every entry + // whose time has arrived. The `while` (not `if`) matters: if the + // game hitched and we skipped a frame, two cues might come due + // in the same update() call and we need to fire them both. + while (s_ogvNextCueIdx < s_ogvScript.entries.size() && + nowMs >= s_ogvScript.entries[s_ogvNextCueIdx].timeMs) + { + const DcssEntry& e = s_ogvScript.entries[s_ogvNextCueIdx]; + #if DCSS_TIMING_TRACE + f64 wallSec = TFE_System::getTime() - s_ogvStartWallTime; + TFE_System::logWrite(LOG_MSG, "DcssTiming", + "[%s] cue #%d expected=%.3fs video=%.3fs wall=%.3fs videoDrift=%+.3fs wallDrift=%+.3fs seq=%d cue=%d musicvol=%d", + s_ogvTraceSceneName, e.index, + e.timeMs / 1000.0, videoTimeSec, wallSec, + videoTimeSec - (e.timeMs / 1000.0), + wallSec - (e.timeMs / 1000.0), + e.seq, e.cue, e.musicVol); + #endif + + // Order matters here: sequence changes reload MIDI, so we do + // that first, then fire the cue within the new sequence, then + // apply any volume override. A cue against the *old* sequence + // would be wrong. + if (e.seq > 0) { lmusic_setSequence(e.seq); } + if (e.cue > 0) { lmusic_setCuePoint(e.cue); } + if (e.musicVol > 0) { ogvCutscene_applyMusicVolume(e.musicVol); } + s_ogvNextCueIdx++; + } + } + + // Pull the active SRT entry for the current playback time and push it + // to the caption system. Called per frame; the caption system itself + // handles the on-screen rendering. + // + // We clearActiveCaptions() before enqueueing a fresh one, so the + // previous caption's timer doesn't linger past its actual end. In the + // "no active entry" case (between lines of dialogue), we just clear. + static void ogvCutscene_updateCaptions() + { + if (s_ogvSubtitles.empty() || !TFE_A11Y::cutsceneCaptionsEnabled()) { return; } + + // Video time (not wall-clock) so captions stay in sync with the + // visible frame, same as music dispatch. + f64 time = TFE_OgvPlayer::getVideoTime(); + const SrtEntry* entry = srt_getActiveEntry(s_ogvSubtitles, time); + if (entry) + { + TFE_A11Y::Caption caption; + caption.text = entry->text; + caption.env = TFE_A11Y::CC_CUTSCENE; + caption.type = TFE_A11Y::CC_VOICE; + // microseconds remaining = how long the caption system should + // display this. Computed from SRT's end time minus now. + caption.microsecondsRemaining = (s64)((entry->endTime - time) * 1000000.0); + TFE_A11Y::clearActiveCaptions(); + TFE_A11Y::enqueueCaption(caption); + } + else + { + TFE_A11Y::clearActiveCaptions(); + } + } + + // The OGV path's per-frame update. Return value has the same meaning + // as cutscenePlayer_update(): true = keep playing, false = we're done. + static JBool ogvCutscene_update() + { + // Skip check comes first so the user's "get me out of here" press + // is responsive even if the decoder is busy. We special-case + // Alt+Enter (fullscreen toggle) so it doesn't double as a skip. + if (TFE_Input::keyPressed(KEY_ESCAPE) || + (TFE_Input::keyPressed(KEY_RETURN) && !TFE_Input::keyDown(KEY_LALT) && !TFE_Input::keyDown(KEY_RALT)) || + TFE_Input::keyPressed(KEY_SPACE)) + { + ogvCutscene_teardown(); + return JFALSE; + } + + // Decode the next frame(s) as needed and render to the backbuffer. + // Returns false when the decoder hits EOF or fails; either way we + // teardown and report "cutscene ended." + if (!TFE_OgvPlayer::update()) + { + ogvCutscene_teardown(); + return JFALSE; + } + + // Cue dispatch AFTER the frame update, so videoTime reflects the + // frame we just presented. + ogvCutscene_dispatchCues(); + ogvCutscene_updateCaptions(); + return JTRUE; + } +#endif + + // ====================================================================== + // Public entry points + // ====================================================================== + + // Start playing cutscene `sceneId`. Returns true if playback began; + // false means the scene wasn't found or cutscenes are disabled. + // + // On success, subsequent cutscene_update() calls drive the playback. + // This is the same contract as the original TFE code - we just added + // the OGV attempt as a first-choice path before the LFD fallback. JBool cutscene_play(s32 sceneId) { if (!s_enabled || !s_playSeq) { return JFALSE; } + + // Apply the user's configured volumes to both audio paths. The + // DCSS musicvol: directive (if any) will layer on top of this. TFE_Settings_Sound* soundSettings = TFE_Settings::getSoundSettings(); TFE_Audio::setVolume(soundSettings->cutsceneSoundFxVolume * soundSettings->masterVolume); TFE_MidiPlayer::setVolume(soundSettings->cutsceneMusicVolume * soundSettings->masterVolume); - // Search for the requested scene. + // Validate the scene id exists in our catalog before we commit + // to either path. (findScene does this too, but for the OGV + // check we want to fail fast if the id is bogus.) s32 found = 0; for (s32 i = 0; !found && s_playSeq[i].id != SCENE_EXIT; i++) { - if (s_playSeq[i].id == sceneId) - { - found = 1; - break; - } + if (s_playSeq[i].id == sceneId) { found = 1; break; } } if (!found) return JFALSE; - // Re-initialize the canvas, so cutscenes run at the correct resolution even if it was changed for gameplay - // (i.e. high resolution support). + +#ifdef ENABLE_OGV_CUTSCENES + // Try the remastered path first. If it returns false, no OGV is + // available for this scene (or the feature is disabled) - fall + // through to the LFD path. + if (tryPlayOgvCutscene(sceneId)) + { + s_playing = JTRUE; + return JTRUE; + } +#endif + + // Reset the Landru canvas to 320x200. The game might have + // switched to a higher resolution for gameplay, but the LFD + // FILM assets are all 320x200 and expect to draw into that + // virtual framebuffer. lcanvas_init(320, 200); - - // The original code then starts the cutscene loop here, and then returns when done. - // Instead we set a bool and then the calling code will call 'update' until it returns false. + + // Kick off the LFD path. Future cutscene_update() calls will + // route through cutscenePlayer_update() based on s_ogvPlaying + // being false. s_playing = JTRUE; cutscenePlayer_start(sceneId); return JTRUE; } + // Per-frame update. Returns true while a cutscene is still playing. + // The outer game loop calls this every frame until it returns false, + // at which point control returns to the next game mode. JBool cutscene_update() { if (!s_playing) { return JFALSE; } +#ifdef ENABLE_OGV_CUTSCENES + if (s_ogvPlaying) + { + s_playing = ogvCutscene_update(); + return s_playing; + } +#endif + s_playing = cutscenePlayer_update(); return s_playing; } + // ====================================================================== + // Legacy settings interface (unchanged from stock TFE) + // ====================================================================== + void cutscene_enable(s32 enable) { s_enabled = enable; @@ -95,4 +550,4 @@ namespace TFE_DarkForces { return s_musicVolume; } -} // TFE_DarkForces \ No newline at end of file +} // TFE_DarkForces diff --git a/TheForceEngine/TFE_DarkForces/Remaster/dcssParser.cpp b/TheForceEngine/TFE_DarkForces/Remaster/dcssParser.cpp new file mode 100644 index 000000000..d5a8a1b71 --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/dcssParser.cpp @@ -0,0 +1,376 @@ +#include "dcssParser.h" +#include +#include +#include +#include +#include +#include +#include + +//============================================================================ +// DCSS parser implementation +//============================================================================ +// +// The remaster's parser (decompiled from khonsu at sub_140073940) is a small +// state machine that walks the file a line at a time. We mirror that +// structure here rather than, say, using TFE_Parser, because: +// +// 1. The DCSS format is block-oriented (index / timestamp / directives / +// blank line), not key-value. TFE_Parser is great for INI-ish data; it +// would fight us on the blank-line-as-separator rule. +// +// 2. We want to match the remaster's quirks exactly, including timestamps +// with typos that parse correctly (e.g. "00:00:58:827" where the ms +// separator should have been a comma). A stricter parser would reject +// those and desync the stock .dcss files. +// +// 3. The whole thing is <500 lines of state. No framework needed. +// +namespace TFE_DarkForces +{ + // ---------------------------------------------------------------------- + // Timestamp parser + // ---------------------------------------------------------------------- + // + // Canonical form is SRT's "HH:MM:SS,mmm". Real life is messier: the + // stock remaster ships kflyby.dcss with "00:00:58:827" (colon before ms) + // and logo.dcss with "00:1:50,487" (minute field missing a leading + // zero). Neither of these would parse with a strict sscanf("%d:%d:%d,%d") + // pattern. + // + // So we walk character by character. Any of ':' ',' '.' ends the current + // field. Digit counts per field are free-form (we cap ms at 3 digits to + // avoid overflow, but that's the only limit). + // + // Returns false on total garbage, including: + // - A separator before any digit ("unexpected punctuation") + // - Too few fields (< 3 colon-separated groups; i.e. no hours field) + // - Any non-digit, non-separator, non-whitespace character + // + static bool parseTimestampMs(const char* line, size_t len, u64& outMs) + { + u64 fields[4] = {}; // hours, minutes, seconds, milliseconds + s32 fieldIdx = 0; // which of the four we're currently filling + s32 digits = 0; // digits seen in current field (for ms cap) + bool anyDigit = false; // have we seen *any* digit yet? + + for (size_t i = 0; i < len && fieldIdx < 4; i++) + { + char c = line[i]; + if (c >= '0' && c <= '9') + { + // Drop extra ms digits rather than overflow. "123456" ms -> 123. + if (fieldIdx == 3 && digits >= 3) { continue; } + fields[fieldIdx] = fields[fieldIdx] * 10 + (u64)(c - '0'); + digits++; + anyDigit = true; + } + else if (c == ':' || c == ',' || c == '.') + { + // Any of these ends the current field. This is what lets us + // accept "00:00:58:827" (all colons) as the same thing as + // "00:00:58,827" - the remaster's parser does the same. + if (!anyDigit) { return false; } // "::01" is garbage + fieldIdx++; + digits = 0; + } + else if (c == ' ' || c == '\t') + { + // Silent-tolerate whitespace, primarily for trailing space at + // end of line. Inline whitespace between digits of a single + // field would still break since we'd keep reading digits. + } + else + { + return false; + } + } + + // Need at least HH:MM:SS (fieldIdx advances on separators, so three + // fields means we saw at least two separators = fieldIdx >= 2). A + // bare "12:34" gets rejected here, as intended. + if (fieldIdx < 2) { return false; } + + outMs = fields[3] + 1000 * (fields[2] + 60 * (fields[1] + 60 * fields[0])); + return true; + } + + // ---------------------------------------------------------------------- + // Line reader + // ---------------------------------------------------------------------- + // + // Returns a pointer to the next line's first character and writes its + // length (excluding the EOL bytes) into lineLen. Advances `pos` past the + // line terminator (handles LF, CRLF, or lone CR). Returns nullptr at EOF. + // + // The returned pointer is NOT null-terminated - it's a view into the + // caller's buffer. Downstream consumers must respect lineLen. + // + static const char* readLine(const char* buffer, size_t size, size_t& pos, size_t& lineLen) + { + if (pos >= size) { return nullptr; } + const char* start = buffer + pos; + const char* end = buffer + size; + const char* p = start; + while (p < end && *p != '\n' && *p != '\r') { p++; } + lineLen = (size_t)(p - start); + + // Consume whichever EOL style is present. CRLF is two chars, LF or + // lone CR is one. + if (p < end && *p == '\r') { p++; } + if (p < end && *p == '\n') { p++; } + pos = (size_t)(p - buffer); + return start; + } + + // ---------------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------------- + + // "Is this line nothing but whitespace?" Blank lines are the entry + // separator in DCSS, so we need a strict definition. + static bool lineIsBlank(const char* line, size_t len) + { + for (size_t i = 0; i < len; i++) + { + if (line[i] != ' ' && line[i] != '\t') { return false; } + } + return true; + } + + // Comments start at the first non-whitespace character if it's '#' or + // '//'. This isn't in the remaster's format - we added it for modder + // convenience (so you can annotate what each cue is for). + static bool lineIsComment(const char* line, size_t len) + { + size_t i = 0; + while (i < len && (line[i] == ' ' || line[i] == '\t')) { i++; } + if (i >= len) { return false; } + if (line[i] == '#') { return true; } + if (i + 1 < len && line[i] == '/' && line[i + 1] == '/') { return true; } + return false; + } + + // "%.*s" expects an int for its length argument. size_t is usually wider + // than int on 64-bit, so clamp it to avoid implementation-defined + // narrowing when logging user-controlled line data. + static int logLen(size_t n) + { + return (n > (size_t)INT_MAX) ? INT_MAX : (int)n; + } + + // Parse an integer from a non-null-terminated buffer view. We copy into + // a tiny local buffer and null-terminate, then strtol it. Done this way + // because strtol needs a null-terminated string and we can't safely + // assume there's one after (line + len) in the caller's buffer. + // + // 32 bytes is plenty - any integer we care about fits in 11 digits plus + // sign, and DCSS integers are in range [0, 127] at the extremes. + static s32 parseIntBounded(const char* line, size_t len) + { + char buf[32]; + size_t n = (len < sizeof(buf) - 1) ? len : sizeof(buf) - 1; + memcpy(buf, line, n); + buf[n] = 0; + return (s32)strtol(buf, nullptr, 10); + } + + // Directive prefix match: "seq: " / "cue: " / "musicvol: ". The trailing + // space is part of the prefix to avoid false matches (e.g. "sequel: 5" + // wouldn't match "seq: "). + static bool startsWith(const char* line, size_t len, const char* prefix) + { + size_t plen = strlen(prefix); + return len >= plen && memcmp(line, prefix, plen) == 0; + } + + // After a prefix match, parse the trailing integer. Skips the prefix + // bytes and runs the same bounded-int routine over what's left. + static s32 parseIntAfter(const char* line, size_t len, const char* prefix) + { + size_t plen = strlen(prefix); + if (len <= plen) { return 0; } + char buf[32]; + size_t n = len - plen; + if (n >= sizeof(buf)) { n = sizeof(buf) - 1; } + memcpy(buf, line + plen, n); + buf[n] = 0; + return (s32)strtol(buf, nullptr, 10); + } + + // ---------------------------------------------------------------------- + // Main parse loop + // ---------------------------------------------------------------------- + // + // State machine (rough): + // + // start ---[+credits]---> header_flags + // start ---[+openingcredits]---> header_flags + // start ---[digit]---> expect_timestamp ---> expect_directives ---> (blank) ---> start + // + // We're lenient about: + // - Extra blank lines between entries + // - Comments anywhere (top, between entries, inside an entry's + // directive block) + // - Unknown directives (silently ignored for forward compatibility) + // - Out-of-order entry indices (logged, but accepted; we re-sort at + // the end by timestamp) + // + bool dcss_parse(const char* buffer, size_t size, DcssScript& out) + { + out.creditsFlag = false; + out.openingCreditsFlag = false; + out.entries.clear(); + if (!buffer || size == 0) { return false; } + + size_t pos = 0; + + // Skip a UTF-8 BOM if present. The remaster's own parser does this + // (khonsu:250830-ish); some text editors insert a BOM by default on + // Windows, so it's worth handling gracefully. + if (size >= 3 && (u8)buffer[0] == 0xEF && (u8)buffer[1] == 0xBB && (u8)buffer[2] == 0xBF) + { + pos = 3; + } + + s32 expectedIndex = 1; // For out-of-order warnings only. + const char* line = nullptr; + size_t lineLen = 0; + + while (pos < size) + { + // Skip leading blank lines and comments between blocks. The + // do/while is so we always read at least one line - the outer + // while(pos.dcss, one per cutscene. +// +// We reverse-engineered the format from the remaster binary (khonsu, the +// Kex4 engine). The parser there lives around sub_140073940; we match its +// behavior closely enough that every stock .dcss file in the remaster +// parses here identically. +// +// Why we keep using the remaster's format rather than inventing our own: +// - Zero-translation compatibility: a user who points TFE at the remaster +// install gets timed MIDI cues with no extra authoring. +// - Modders can follow published guides / look at stock files / hand-edit +// with a text editor. No pipeline tooling required. +// - If the remaster ever adds new directives, forward-compat is easy +// (we silently ignore unknown lines). +// +// --------------------------------------------------------------------------- +// FORMAT OVERVIEW (see dcss-format.md for the complete spec) +// --------------------------------------------------------------------------- +// +// The file is a list of blocks, blank-line separated. Each block is: +// +// <1-based index> +// (tolerates ','/':'/'.' as ms separator) +// seq: (optional, 1..20) +// cue: (optional, 1..20) +// musicvol: <0..127> (optional, percentage where 100 = base) +// +// Two optional header flags can appear before the first block: +// +credits +// +openingcredits +// +// Example (arcfly.dcss, verbatim from the remaster): +// +// 1 +// 00:00:00,327 +// seq: 5 +// cue: 1 +// +// 2 +// 00:00:06,213 +// cue: 2 +// +// Comments start with '#' or '//' and may appear on their own line. +// +// --------------------------------------------------------------------------- +// FOR MODDERS +// --------------------------------------------------------------------------- +// +// Drop a plain-text .dcss next to your .ogv. You can author +// these in Notepad; no tools required. See Documentation/markdown/ +// remaster-cutscenes/modding-guide.md for a walkthrough. +// +#include +#include + +namespace TFE_DarkForces +{ + // A single cue point parsed from a .dcss file. At runtime, when the OGV's + // playback time reaches timeMs, any fields that are "set" get dispatched: + // + // - seq > 0 -> lmusic_setSequence(seq) + // - cue > 0 -> lmusic_setCuePoint(cue) + // - musicVol > 0 -> TFE_MidiPlayer::setVolume(base * musicVol/100) + // + // Zero / negative means "leave it alone," so a typical mid-scene entry + // has seq=0 and just changes the cue. + struct DcssEntry + { + u64 timeMs; // Absolute playback time in ms (relative to scene start). + s32 seq; // iMuse sequence id to (re-)start. 0 = no change. + s32 cue; // iMuse cue point to fire. 0 = no change. + s32 musicVol; // Music volume override, as a %. <=0 = no change. + s32 index; // 1-based entry number. Informational; not dispatched. + }; + + // Top-level parse result. The entries vector is sorted ascending by timeMs + // after parse, so the runtime dispatcher can just walk it forward. + struct DcssScript + { + bool creditsFlag; // "+credits" flag was present in the header + bool openingCreditsFlag; // "+openingcredits" flag was present + std::vector entries; + }; + + // Parse a DCSS file that's already in memory. Returns true if anything + // useful was recovered (at least one entry, or at least one header flag). + // Malformed entries are skipped with a warning; never throws. + bool dcss_parse(const char* buffer, size_t size, DcssScript& out); + + // Convenience wrapper that reads the file from disk. Silent-false on + // "file doesn't exist" (a common legitimate case when a modder ships an + // OGV but no DCSS); logs a warning only for other I/O problems. + bool dcss_loadFromFile(const char* path, DcssScript& out); +} diff --git a/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.cpp b/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.cpp new file mode 100644 index 000000000..fb1d696a7 --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.cpp @@ -0,0 +1,749 @@ +#include "ogvPlayer.h" + +#ifdef ENABLE_OGV_CUTSCENES + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +namespace TFE_OgvPlayer +{ + static const u32 AUDIO_BUFFER_SIZE = 32768; // per-channel ring buffer size + static const u32 OGG_BUFFER_SIZE = 4096; + + // Ogg / Theora / Vorbis state + static FILE* s_file = nullptr; + + static ogg_sync_state s_syncState; + static ogg_stream_state s_theoraStream; + static ogg_stream_state s_vorbisStream; + + static th_info s_theoraInfo; + static th_comment s_theoraComment; + static th_setup_info* s_theoraSetup = nullptr; + static th_dec_ctx* s_theoraDec = nullptr; + + static vorbis_info s_vorbisInfo; + static vorbis_comment s_vorbisComment; + static vorbis_dsp_state s_vorbisDsp; + static vorbis_block s_vorbisBlock; + + static bool s_hasTheora = false; + static bool s_hasVorbis = false; + static bool s_theoraStreamInited = false; + static bool s_vorbisStreamInited = false; + + // Playback state + static bool s_playing = false; + static bool s_initialized = false; + static f64 s_videoTime = 0.0; + static f64 s_audioTime = 0.0; + static f64 s_playbackStart = 0.0; + static bool s_firstFrame = true; + + // GPU resources + static Shader s_yuvShader; + static TextureGpu* s_texY = nullptr; + static TextureGpu* s_texCb = nullptr; + static TextureGpu* s_texCr = nullptr; + static VertexBuffer s_vertexBuffer; + static IndexBuffer s_indexBuffer; + static s32 s_scaleOffsetId = -1; + + // Audio ring buffer (stereo interleaved f32) + static f32* s_audioRingBuffer = nullptr; + static volatile u32 s_audioWritePos = 0; + static volatile u32 s_audioReadPos = 0; + static u32 s_audioRingSize = 0; + static f64 s_resampleAccum = 0.0; + + // Forward declarations + static bool readOggHeaders(); + static bool decodeVideoFrame(); + static void decodeAudioPackets(); + static bool demuxPage(ogg_page* page); + static bool bufferOggData(); + static void uploadYuvFrame(th_ycbcr_buffer ycbcr); + static void renderFrame(); + static void audioCallback(f32* buffer, u32 bufferSize, f32 systemVolume); + static void freeGpuResources(); + static void freeOggResources(); + + // Fullscreen quad vertex layout + struct QuadVertex + { + f32 x, y; + f32 u, v; + }; + + static const AttributeMapping c_quadAttrMapping[] = + { + {ATTR_POS, ATYPE_FLOAT, 2, 0, false}, + {ATTR_UV, ATYPE_FLOAT, 2, 0, false}, + }; + static const u32 c_quadAttrCount = TFE_ARRAYSIZE(c_quadAttrMapping); + + bool init() + { + if (s_initialized) { return true; } + + if (!s_yuvShader.load("Shaders/yuv2rgb.vert", "Shaders/yuv2rgb.frag")) + { + TFE_System::logWrite(LOG_ERROR, "OgvPlayer", "Failed to load YUV->RGB shader."); + return false; + } + s_yuvShader.bindTextureNameToSlot("TexY", 0); + s_yuvShader.bindTextureNameToSlot("TexCb", 1); + s_yuvShader.bindTextureNameToSlot("TexCr", 2); + s_scaleOffsetId = s_yuvShader.getVariableId("ScaleOffset"); + + const QuadVertex vertices[] = + { + {0.0f, 0.0f, 0.0f, 0.0f}, + {1.0f, 0.0f, 1.0f, 0.0f}, + {1.0f, 1.0f, 1.0f, 1.0f}, + {0.0f, 1.0f, 0.0f, 1.0f}, + }; + const u16 indices[] = { 0, 1, 2, 0, 2, 3 }; + s_vertexBuffer.create(4, sizeof(QuadVertex), c_quadAttrCount, c_quadAttrMapping, false, (void*)vertices); + s_indexBuffer.create(6, sizeof(u16), false, (void*)indices); + + s_initialized = true; + return true; + } + + void shutdown() + { + if (!s_initialized) { return; } + close(); + + s_yuvShader.destroy(); + s_vertexBuffer.destroy(); + s_indexBuffer.destroy(); + s_initialized = false; + } + + bool open(const char* filepath) + { + if (!s_initialized) + { + if (!init()) { return false; } + } + if (s_playing) { close(); } + + s_file = fopen(filepath, "rb"); + if (!s_file) + { + TFE_System::logWrite(LOG_ERROR, "OgvPlayer", "Cannot open file: %s", filepath); + return false; + } + + ogg_sync_init(&s_syncState); + th_info_init(&s_theoraInfo); + th_comment_init(&s_theoraComment); + vorbis_info_init(&s_vorbisInfo); + vorbis_comment_init(&s_vorbisComment); + + s_hasTheora = false; + s_hasVorbis = false; + s_theoraStreamInited = false; + s_vorbisStreamInited = false; + s_theoraSetup = nullptr; + s_theoraDec = nullptr; + + if (!readOggHeaders()) + { + TFE_System::logWrite(LOG_ERROR, "OgvPlayer", "Failed to read OGV headers from: %s", filepath); + close(); + return false; + } + + if (!s_hasTheora) + { + TFE_System::logWrite(LOG_ERROR, "OgvPlayer", "No Theora stream found in: %s", filepath); + close(); + return false; + } + + s_theoraDec = th_decode_alloc(&s_theoraInfo, s_theoraSetup); + if (!s_theoraDec) + { + TFE_System::logWrite(LOG_ERROR, "OgvPlayer", "Failed to create Theora decoder."); + close(); + return false; + } + + u32 yW = s_theoraInfo.frame_width; + u32 yH = s_theoraInfo.frame_height; + u32 cW = yW, cH = yH; + if (s_theoraInfo.pixel_fmt == TH_PF_420) + { + cW = (yW + 1) / 2; + cH = (yH + 1) / 2; + } + else if (s_theoraInfo.pixel_fmt == TH_PF_422) + { + cW = (yW + 1) / 2; + } + + s_texY = new TextureGpu(); + s_texCb = new TextureGpu(); + s_texCr = new TextureGpu(); + s_texY->create(yW, yH, TEX_R8, false, MAG_FILTER_LINEAR); + s_texCb->create(cW, cH, TEX_R8, false, MAG_FILTER_LINEAR); + s_texCr->create(cW, cH, TEX_R8, false, MAG_FILTER_LINEAR); + s_texY->setFilter(MAG_FILTER_LINEAR, MIN_FILTER_LINEAR); + s_texCb->setFilter(MAG_FILTER_LINEAR, MIN_FILTER_LINEAR); + s_texCr->setFilter(MAG_FILTER_LINEAR, MIN_FILTER_LINEAR); + + if (s_hasVorbis) + { + vorbis_synthesis_init(&s_vorbisDsp, &s_vorbisInfo); + vorbis_block_init(&s_vorbisDsp, &s_vorbisBlock); + + s_audioRingSize = AUDIO_BUFFER_SIZE * 2; // stereo + s_audioRingBuffer = new f32[s_audioRingSize]; + memset(s_audioRingBuffer, 0, s_audioRingSize * sizeof(f32)); + s_audioWritePos = 0; + s_audioReadPos = 0; + s_resampleAccum = 0.0; + + TFE_Audio::setDirectCallback(audioCallback); + } + + s_videoTime = 0.0; + s_audioTime = 0.0; + s_playbackStart = TFE_System::getTime(); + s_firstFrame = true; + s_playing = true; + + TFE_System::logWrite(LOG_MSG, "OgvPlayer", "Opened OGV: %ux%u, %.2f fps, %s audio (rate=%ld, channels=%d)", + s_theoraInfo.frame_width, s_theoraInfo.frame_height, + (f64)s_theoraInfo.fps_numerator / (f64)s_theoraInfo.fps_denominator, + s_hasVorbis ? "with" : "no", + s_hasVorbis ? s_vorbisInfo.rate : 0, + s_hasVorbis ? s_vorbisInfo.channels : 0); + + return true; + } + + void close() + { + if (s_hasVorbis) + { + // Clear callback, then lock/unlock to wait for the audio thread to finish. + TFE_Audio::setDirectCallback(nullptr); + TFE_Audio::lock(); + TFE_Audio::unlock(); + } + + freeGpuResources(); + freeOggResources(); + + if (s_audioRingBuffer) + { + delete[] s_audioRingBuffer; + s_audioRingBuffer = nullptr; + } + s_audioRingSize = 0; + s_audioWritePos = 0; + s_audioReadPos = 0; + + if (s_file) + { + fclose(s_file); + s_file = nullptr; + } + + s_playing = false; + } + + bool update() + { + if (!s_playing) { return false; } + + if (TFE_Input::keyPressed(KEY_ESCAPE) || TFE_Input::keyPressed(KEY_RETURN) || TFE_Input::keyPressed(KEY_SPACE)) + { + close(); + return false; + } + + f64 elapsed = TFE_System::getTime() - s_playbackStart; + f64 frameDuration = (f64)s_theoraInfo.fps_denominator / (f64)s_theoraInfo.fps_numerator; + + bool gotFrame = false; + while (s_videoTime <= elapsed) + { + if (!decodeVideoFrame()) + { + close(); + return false; + } + s_videoTime += frameDuration; + gotFrame = true; + } + + // Decode audio after video so vorbis gets pages that video demuxing pulled in. + if (s_hasVorbis) + { + decodeAudioPackets(); + } + + s_firstFrame = false; + // Always render; the game loop runs faster than the video framerate. + renderFrame(); + + return s_playing; + } + + bool isPlaying() + { + return s_playing; + } + + // Wall-clock seconds since open(). Useful for profiling playback cost + // and for the cutscene.cpp DCSS timing trace that compares wall-clock + // drift against the video clock. + f64 getPlaybackTime() + { + if (!s_playing) { return 0.0; } + return TFE_System::getTime() - s_playbackStart; + } + + // Intrinsic video time. s_videoTime is maintained by update() as + // "time of the next frame we need to decode" - it advances by + // fps_denom/fps_numer (i.e. one frame) every time we actually decode + // and present a frame. + // + // Since s_videoTime has already been incremented past the frame we + // just presented, we subtract one frame to get "time of the frame + // currently on screen." That's what a cue dispatcher wants. + // + // Returns 0 (not a valid time) when not playing, so the caller can + // special-case that. Also returns 0 right at playback start before + // any frame has been decoded, which is harmless - no cue should be + // firing at negative time. + f64 getVideoTime() + { + if (!s_playing) { return 0.0; } + f64 frameDuration = (f64)s_theoraInfo.fps_denominator / (f64)s_theoraInfo.fps_numerator; + f64 t = s_videoTime - frameDuration; + return t > 0.0 ? t : 0.0; + } + + static bool bufferOggData() + { + if (!s_file) { return false; } + char* buffer = ogg_sync_buffer(&s_syncState, OGG_BUFFER_SIZE); + size_t bytesRead = fread(buffer, 1, OGG_BUFFER_SIZE, s_file); + ogg_sync_wrote(&s_syncState, (int)bytesRead); + return bytesRead > 0; + } + + static bool demuxPage(ogg_page* page) + { + if (s_theoraStreamInited) + { + ogg_stream_pagein(&s_theoraStream, page); + } + if (s_vorbisStreamInited) + { + ogg_stream_pagein(&s_vorbisStream, page); + } + return true; + } + + static bool readOggHeaders() + { + ogg_page page; + ogg_packet packet; + int theoraHeadersNeeded = 0; + int vorbisHeadersNeeded = 0; + + while (true) + { + while (ogg_sync_pageout(&s_syncState, &page) != 1) + { + if (!bufferOggData()) { return s_hasTheora; } + } + + if (ogg_page_bos(&page)) + { + ogg_stream_state test; + ogg_stream_init(&test, ogg_page_serialno(&page)); + ogg_stream_pagein(&test, &page); + ogg_stream_packetpeek(&test, &packet); + + if (!s_hasTheora && th_decode_headerin(&s_theoraInfo, &s_theoraComment, &s_theoraSetup, &packet) >= 0) + { + memcpy(&s_theoraStream, &test, sizeof(test)); + s_theoraStreamInited = true; + s_hasTheora = true; + theoraHeadersNeeded = 3; + ogg_stream_packetout(&s_theoraStream, &packet); + theoraHeadersNeeded--; + } + else if (!s_hasVorbis && vorbis_synthesis_headerin(&s_vorbisInfo, &s_vorbisComment, &packet) >= 0) + { + memcpy(&s_vorbisStream, &test, sizeof(test)); + s_vorbisStreamInited = true; + s_hasVorbis = true; + vorbisHeadersNeeded = 3; + ogg_stream_packetout(&s_vorbisStream, &packet); + vorbisHeadersNeeded--; + } + else + { + ogg_stream_clear(&test); + } + continue; + } + + if (s_theoraStreamInited) + { + ogg_stream_pagein(&s_theoraStream, &page); + } + if (s_vorbisStreamInited) + { + ogg_stream_pagein(&s_vorbisStream, &page); + } + + while (theoraHeadersNeeded > 0) + { + if (ogg_stream_packetout(&s_theoraStream, &packet) != 1) { break; } + if (th_decode_headerin(&s_theoraInfo, &s_theoraComment, &s_theoraSetup, &packet) < 0) + { + TFE_System::logWrite(LOG_ERROR, "OgvPlayer", "Bad Theora header packet."); + return false; + } + theoraHeadersNeeded--; + } + + while (vorbisHeadersNeeded > 0) + { + if (ogg_stream_packetout(&s_vorbisStream, &packet) != 1) { break; } + if (vorbis_synthesis_headerin(&s_vorbisInfo, &s_vorbisComment, &packet) < 0) + { + TFE_System::logWrite(LOG_ERROR, "OgvPlayer", "Bad Vorbis header packet."); + return false; + } + vorbisHeadersNeeded--; + } + + if (theoraHeadersNeeded <= 0 && vorbisHeadersNeeded <= 0) + { + break; + } + } + + return s_hasTheora; + } + + static bool decodeVideoFrame() + { + ogg_packet packet; + ogg_page page; + + while (ogg_stream_packetout(&s_theoraStream, &packet) != 1) + { + while (ogg_sync_pageout(&s_syncState, &page) != 1) + { + if (!bufferOggData()) { return false; } + } + if (s_theoraStreamInited && ogg_page_serialno(&page) == s_theoraStream.serialno) + { + ogg_stream_pagein(&s_theoraStream, &page); + } + if (s_vorbisStreamInited && ogg_page_serialno(&page) == s_vorbisStream.serialno) + { + ogg_stream_pagein(&s_vorbisStream, &page); + } + } + + if (th_decode_packetin(s_theoraDec, &packet, nullptr) == 0) + { + th_ycbcr_buffer ycbcr; + th_decode_ycbcr_out(s_theoraDec, ycbcr); + uploadYuvFrame(ycbcr); + } + + return true; + } + + static u32 audioRingAvailable() + { + u32 w = s_audioWritePos; + u32 r = s_audioReadPos; + return (w >= r) ? (w - r) : (s_audioRingSize - r + w); + } + + // Resample pending vorbis PCM into the ring buffer. Returns false if full. + static bool drainPendingPcm() + { + const f64 resampleStep = (f64)s_vorbisInfo.rate / 44100.0; + f32** pcm; + s32 samples; + while ((samples = vorbis_synthesis_pcmout(&s_vorbisDsp, &pcm)) > 0) + { + s32 channels = s_vorbisInfo.channels; + while (s_resampleAccum < (f64)samples) + { + u32 w = s_audioWritePos; + u32 r = s_audioReadPos; + u32 used = (w >= r) ? (w - r) : (s_audioRingSize - r + w); + if (used >= s_audioRingSize - 2) { return false; } // Ring full + + s32 idx = (s32)s_resampleAccum; + s_audioRingBuffer[w] = pcm[0][idx]; + w = (w + 1) % s_audioRingSize; + s_audioRingBuffer[w] = (channels > 1) ? pcm[1][idx] : pcm[0][idx]; + w = (w + 1) % s_audioRingSize; + s_audioWritePos = w; + s_resampleAccum += resampleStep; + } + s_resampleAccum -= (f64)samples; + vorbis_synthesis_read(&s_vorbisDsp, samples); + } + return true; + } + + static void drainVorbisPackets() + { + if (!drainPendingPcm()) { return; } + + ogg_packet packet; + while (ogg_stream_packetout(&s_vorbisStream, &packet) == 1) + { + if (vorbis_synthesis(&s_vorbisBlock, &packet) == 0) + { + vorbis_synthesis_blockin(&s_vorbisDsp, &s_vorbisBlock); + } + if (!drainPendingPcm()) { return; } + } + } + + static void decodeAudioPackets() + { + if (!s_hasVorbis) { return; } + + drainVorbisPackets(); + + // Keep at least ~0.19s of audio buffered. + ogg_page page; + while (audioRingAvailable() < 8192 * 2) + { + while (ogg_sync_pageout(&s_syncState, &page) != 1) + { + if (!bufferOggData()) { return; } // EOF + } + if (s_vorbisStreamInited && ogg_page_serialno(&page) == s_vorbisStream.serialno) + { + ogg_stream_pagein(&s_vorbisStream, &page); + } + if (s_theoraStreamInited && ogg_page_serialno(&page) == s_theoraStream.serialno) + { + ogg_stream_pagein(&s_theoraStream, &page); + } + drainVorbisPackets(); + } + } + + // Audio thread callback - mixes OGV audio into the output buffer. + static void audioCallback(f32* buffer, u32 frameCount, f32 systemVolume) + { + u32 samplesToFill = frameCount * 2; + + for (u32 i = 0; i < samplesToFill; i++) + { + if (s_audioReadPos != s_audioWritePos) + { + buffer[i] += s_audioRingBuffer[s_audioReadPos] * systemVolume; + s_audioReadPos = (s_audioReadPos + 1) % s_audioRingSize; + } + } + } + + static void uploadYuvFrame(th_ycbcr_buffer ycbcr) + { + if (!s_texY || !s_texCb || !s_texCr) { return; } + + { // Y plane + u32 w = ycbcr[0].width; + u32 h = ycbcr[0].height; + if (ycbcr[0].stride == (s32)w) + { + s_texY->update(ycbcr[0].data, w * h); + } + else + { + std::vector temp(w * h); + for (u32 row = 0; row < h; row++) + { + memcpy(&temp[row * w], ycbcr[0].data + row * ycbcr[0].stride, w); + } + s_texY->update(temp.data(), w * h); + } + } + + { // Cb plane + u32 w = ycbcr[1].width; + u32 h = ycbcr[1].height; + if (ycbcr[1].stride == (s32)w) + { + s_texCb->update(ycbcr[1].data, w * h); + } + else + { + std::vector temp(w * h); + for (u32 row = 0; row < h; row++) + { + memcpy(&temp[row * w], ycbcr[1].data + row * ycbcr[1].stride, w); + } + s_texCb->update(temp.data(), w * h); + } + } + + { // Cr plane + u32 w = ycbcr[2].width; + u32 h = ycbcr[2].height; + if (ycbcr[2].stride == (s32)w) + { + s_texCr->update(ycbcr[2].data, w * h); + } + else + { + std::vector temp(w * h); + for (u32 row = 0; row < h; row++) + { + memcpy(&temp[row * w], ycbcr[2].data + row * ycbcr[2].stride, w); + } + s_texCr->update(temp.data(), w * h); + } + } + } + + static void renderFrame() + { + TFE_RenderBackend::unbindRenderTarget(); + DisplayInfo display; + TFE_RenderBackend::getDisplayInfo(&display); + TFE_RenderBackend::setViewport(0, 0, display.width, display.height); + glClear(GL_COLOR_BUFFER_BIT); + + TFE_RenderState::setStateEnable(false, STATE_CULLING | STATE_BLEND | STATE_DEPTH_TEST); + + f32 dispW = (f32)display.width; + f32 dispH = (f32)display.height; + f32 vidW = (f32)s_theoraInfo.pic_width; + f32 vidH = (f32)s_theoraInfo.pic_height; + + f32 scaleX, scaleY, offsetX, offsetY; + f32 vidAspect = vidW / vidH; + f32 dispAspect = dispW / dispH; + + if (vidAspect > dispAspect) + { + scaleX = 2.0f; + scaleY = 2.0f * (dispAspect / vidAspect); + offsetX = -1.0f; + offsetY = -scaleY * 0.5f; + } + else + { + scaleX = 2.0f * (vidAspect / dispAspect); + scaleY = 2.0f; + offsetX = -scaleX * 0.5f; + offsetY = -1.0f; + } + + const f32 scaleOffset[] = { scaleX, scaleY, offsetX, offsetY }; + + s_yuvShader.bind(); + s_yuvShader.setVariable(s_scaleOffsetId, SVT_VEC4, scaleOffset); + + s_texY->bind(0); + s_texCb->bind(1); + s_texCr->bind(2); + + s_vertexBuffer.bind(); + s_indexBuffer.bind(); + + TFE_RenderBackend::drawIndexedTriangles(2, sizeof(u16)); + + s_vertexBuffer.unbind(); + s_indexBuffer.unbind(); + + TextureGpu::clearSlots(3, 0); + Shader::unbind(); + + // Skip the normal virtual display blit since we drew directly to the backbuffer. + TFE_RenderBackend::setSkipDisplayAndClear(true); + } + + static void freeGpuResources() + { + if (s_texY) { delete s_texY; s_texY = nullptr; } + if (s_texCb) { delete s_texCb; s_texCb = nullptr; } + if (s_texCr) { delete s_texCr; s_texCr = nullptr; } + } + + static void freeOggResources() + { + if (s_theoraDec) + { + th_decode_free(s_theoraDec); + s_theoraDec = nullptr; + } + if (s_theoraSetup) + { + th_setup_free(s_theoraSetup); + s_theoraSetup = nullptr; + } + + if (s_hasVorbis) + { + vorbis_block_clear(&s_vorbisBlock); + vorbis_dsp_clear(&s_vorbisDsp); + } + + if (s_vorbisStreamInited) + { + ogg_stream_clear(&s_vorbisStream); + s_vorbisStreamInited = false; + } + if (s_theoraStreamInited) + { + ogg_stream_clear(&s_theoraStream); + s_theoraStreamInited = false; + } + + th_comment_clear(&s_theoraComment); + th_info_clear(&s_theoraInfo); + vorbis_comment_clear(&s_vorbisComment); + vorbis_info_clear(&s_vorbisInfo); + + ogg_sync_clear(&s_syncState); + + s_hasTheora = false; + s_hasVorbis = false; + } + +} // TFE_OgvPlayer + +#endif // ENABLE_OGV_CUTSCENES diff --git a/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.h b/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.h new file mode 100644 index 000000000..e875f0e2f --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.h @@ -0,0 +1,58 @@ +#pragma once +//============================================================================ +// OGV cutscene player +//============================================================================ +// +// Plays Ogg Theora (video) + Vorbis (audio) files, decoded with libtheora +// and libvorbis, rendered via a GPU YUV->RGB shader (progs/yuv2rgb.shader). +// Handles a single stream at a time - we never run two cutscenes concurrent. +// +// Used exclusively by cutscene.cpp's OGV path. If you want to play a movie +// from some other game context, that wiring doesn't exist yet. +// +#include + +#ifdef ENABLE_OGV_CUTSCENES + +namespace TFE_OgvPlayer +{ + // Create GPU resources (shader, VBO/IBO, Y/Cb/Cr texture slots). Must + // be called after the render backend is up. Called lazily on first + // open() if not invoked explicitly. + bool init(); + + // Destroy GPU resources. Safe to call multiple times. + void shutdown(); + + // Open a file and start playback. Returns false on decoder setup + // failure (bad codec, corrupt headers, missing audio stream we can't + // tolerate, etc.); the caller should fall back to an alternate + // cutscene path. + bool open(const char* filepath); + + // Stop playback and release per-stream state. Idempotent - calling + // close() on an already-closed player is a no-op. + void close(); + + // Decode the next video frame (if one is due), pump audio, and render + // the current frame to the backbuffer. Returns false when the stream + // ends or the user presses a skip key. Driven once per game-loop + // iteration while a cutscene is playing. + bool update(); + + bool isPlaying(); + + // Wall-clock time since open(), in seconds. This advances in real + // time regardless of whether frames are being decoded - useful for + // measuring playback cost, not for syncing. + f64 getPlaybackTime(); + + // Intrinsic video time: advances by 1/fps each time a frame is + // decoded and presented. This is what you want for synchronizing + // anything to the visible frame (music cues, captions, etc.), + // because if the game loop hitches, this clock stays locked to + // the image on screen while wall-clock races ahead. + f64 getVideoTime(); +} + +#endif // ENABLE_OGV_CUTSCENES diff --git a/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.cpp b/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.cpp new file mode 100644 index 000000000..653c1e35a --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.cpp @@ -0,0 +1,448 @@ +#include "remasterCutscenes.h" +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#endif +#include +#include +#include +#include + +//============================================================================ +// Remastered cutscene path resolution +//============================================================================ +// +// The remaster (NightDive's Kex engine port, codename "khonsu") packages +// its OGV cutscenes alongside the original DOS game data. Depending on how +// the user got it, the actual filesystem layout is one of: +// +// Steam install: /common/STAR WARS Dark Forces Remaster/ +// dark.gob (original DF data) +// DarkEX.kpf (zip with DCSS scripts) +// movies/.ogv (the video files) +// movies/_.ogv (localized variants) +// GOG install: similar, different root +// Custom: user-specified via df_remasterCutscenesPath +// +// DCSS scripts live inside DarkEX.kpf at cutscene_scripts/*.dcss. For TFE +// modding purposes we extract them to a filesystem sibling of movies/, so +// our on-disk layout looks like: +// +// / +// movies/.ogv +// cutscene_scripts/.dcss +// Subtitles/_.srt (optional) +// +// This file's job: given a CutsceneState from cutscene.lst, figure out the +// three concrete file paths for its OGV / DCSS / SRT. +// +namespace TFE_DarkForces +{ + // ------------------------------------------------------------------ + // Module state + // ------------------------------------------------------------------ + // All singletons. Cutscenes are driven serially, so we don't need + // per-caller state. The three static char buffers below get reused + // across every call to getVideoPath / getDcssPath / getSubtitlePath, + // so callers must consume the returned pointer before asking again. + static bool s_initialized = false; + static bool s_available = false; + + // Directory paths (with trailing slash) where we found each kind of + // file. Cached at init time so per-cutscene lookups are fast. + static std::string s_videoBasePath; + static std::string s_scriptBasePath; + static std::string s_subtitleBasePath; + + // Return buffers. Static so we can hand a const char* back to the + // caller without transferring ownership. A bit old-school, but + // matches the rest of TFE's path-handling conventions. + static char s_videoPathResult[TFE_MAX_PATH]; + static char s_scriptPathResult[TFE_MAX_PATH]; + static char s_subtitlePathResult[TFE_MAX_PATH]; + + // ------------------------------------------------------------------ + // Name utilities + // ------------------------------------------------------------------ + + // ASCII lowercase, bounded to maxLen (the size of the source buffer). + // We use this for filename normalization since the remaster's files + // are all lowercase on disk but cutscene.lst's archive field is + // uppercase (e.g. "ARCFLY.LFD"). + static std::string lower(const char* src, size_t maxLen) + { + std::string out; + out.reserve(maxLen); + for (size_t i = 0; i < maxLen && src[i]; i++) + { + out.push_back((char)tolower((u8)src[i])); + } + return out; + } + + // "ARCFLY.LFD" or "arcfly" -> "arcfly". + // + // The remaster keys its lookups on the scene *name* (column 3 of + // cutscene.lst), not the archive name. For stock data those are + // always the same string anyway (ARCFLY.LFD holds the "arcfly" + // scene), but they could diverge in a mod. Prefer scene name; fall + // back to archive basename so we don't regress on mods that happen + // to set scene="". + static std::string sceneBaseName(const CutsceneState* scene) + { + if (!scene) { return {}; } + if (scene->scene[0]) { return lower(scene->scene, sizeof(scene->scene)); } + + std::string name = lower(scene->archive, sizeof(scene->archive)); + size_t dot = name.rfind(".lfd"); + if (dot != std::string::npos) { name = name.substr(0, dot); } + return name; + } + + // ------------------------------------------------------------------ + // Video path detection + // ------------------------------------------------------------------ + // + // The remaster stores videos in either a "movies/" or "Cutscenes/" + // subdirectory depending on which release you have. Try both. + static const char* s_subdirNames[] = { "movies/", "Cutscenes/" }; + static const int s_subdirCount = 2; + + // Given a candidate root, check if either subdirectory exists and + // looks like our video layout. Returns true on the first hit and + // caches the full path (including trailing slash) in s_videoBasePath. + static bool tryBasePath(const char* basePath) + { + char testPath[TFE_MAX_PATH]; + for (int i = 0; i < s_subdirCount; i++) + { + snprintf(testPath, TFE_MAX_PATH, "%s%s", basePath, s_subdirNames[i]); + if (FileUtil::directoryExists(testPath)) + { + s_videoBasePath = testPath; + TFE_System::logWrite(LOG_MSG, "Remaster", "Found remaster cutscenes at: %s", testPath); + return true; + } + } + return false; + } + + // Try every plausible location for the remaster data, in a defined + // priority order. First hit wins. + // + // Priority rationale: + // 1. User-configured path is always king. + // 2. PATH_REMASTER_DOCS is a platform-specific override TFE uses on + // consoles / packaged distributions. + // 3. sourcePath lets the user install the remaster to whatever + // directory they want without hardcoding a registry lookup. + // 4. Registry lookup catches the common Steam/GOG install locations + // on Windows without requiring config. + // 5. Program directory is a last-ditch "they dropped files next to + // the EXE" case. + static bool detectVideoPath() + { +#ifdef ENABLE_OGV_CUTSCENES + // 1. Explicit user override from settings.ini. If they bothered to + // set this, they mean it - don't silently override with a + // registry lookup. + const TFE_Settings_Game* gameSettings = TFE_Settings::getGameSettings(); + if (gameSettings->df_remasterCutscenesPath[0]) + { + std::string custom = gameSettings->df_remasterCutscenesPath; + // Trailing slash is required for our snprintf patterns below. + if (custom.back() != '/' && custom.back() != '\\') { custom += '/'; } + if (FileUtil::directoryExists(custom.c_str())) + { + s_videoBasePath = custom; + TFE_System::logWrite(LOG_MSG, "Remaster", "Using custom cutscene path: %s", custom.c_str()); + return true; + } + // Fall through to other discovery; the user might have put + // the path in but then moved the files. + } +#endif + + // 2. Platform-configured remaster docs path (currently unused on + // desktop; retained for console builds). + if (TFE_Paths::hasPath(PATH_REMASTER_DOCS)) + { + if (tryBasePath(TFE_Paths::getPath(PATH_REMASTER_DOCS))) + return true; + } + + // 3. Same sourcePath they use for the original Dark Forces. If + // they pointed it at the remaster install, movies/ will be + // right there. + const char* sourcePath = TFE_Settings::getGameHeader("Dark Forces")->sourcePath; + if (sourcePath && sourcePath[0]) + { + if (tryBasePath(sourcePath)) + return true; + } + +#ifdef _WIN32 + // 4. Windows registry: check both the standard Steam install and + // the "TM" (trademark) variant that was briefly used. GOG has + // its own registry entries handled elsewhere. + { + char remasterPath[TFE_MAX_PATH] = {}; + if (WindowsRegistry::getSteamPathFromRegistry( + TFE_Settings::c_steamRemasterProductId[Game_Dark_Forces], + TFE_Settings::c_steamRemasterLocalPath[Game_Dark_Forces], + TFE_Settings::c_steamRemasterLocalSubPath[Game_Dark_Forces], + TFE_Settings::c_validationFile[Game_Dark_Forces], + remasterPath)) + { + if (tryBasePath(remasterPath)) + return true; + } + if (WindowsRegistry::getSteamPathFromRegistry( + TFE_Settings::c_steamRemasterProductId[Game_Dark_Forces], + TFE_Settings::c_steamRemasterTMLocalPath[Game_Dark_Forces], + TFE_Settings::c_steamRemasterLocalSubPath[Game_Dark_Forces], + TFE_Settings::c_validationFile[Game_Dark_Forces], + remasterPath)) + { + if (tryBasePath(remasterPath)) + return true; + } + } +#endif + + // 5. Last resort: right next to the TFE executable. + if (tryBasePath(TFE_Paths::getPath(PATH_PROGRAM))) + return true; + + return false; + } + + // ------------------------------------------------------------------ + // Script path detection + // ------------------------------------------------------------------ + // + // Once we know where movies/ is, look for cutscene_scripts/ as its + // sibling. That's how the remaster's DarkEX.kpf lays things out: + // + // / + // movies/ + // cutscene_scripts/ <- we're looking for this + // + // If a modder drops everything in one directory, we also check for + // cutscene_scripts/ as a child of movies/ as a fallback. + static void detectScriptPath() + { + if (s_videoBasePath.empty()) { return; } + + // Walk back one directory. s_videoBasePath ends in "movies/" or + // "Cutscenes/"; strip that component to get the parent. + std::string root = s_videoBasePath; + if (!root.empty() && (root.back() == '/' || root.back() == '\\')) { root.pop_back(); } + size_t slash = root.find_last_of("/\\"); + if (slash != std::string::npos) { root = root.substr(0, slash + 1); } + else { root += '/'; } + + // Canonical location: sibling of movies/. + char testPath[TFE_MAX_PATH]; + snprintf(testPath, TFE_MAX_PATH, "%scutscene_scripts/", root.c_str()); + if (FileUtil::directoryExists(testPath)) + { + s_scriptBasePath = testPath; + TFE_System::logWrite(LOG_MSG, "Remaster", "Found cutscene scripts at: %s", testPath); + return; + } + + // Modder convenience: cutscene_scripts/ inside movies/. + snprintf(testPath, TFE_MAX_PATH, "%scutscene_scripts/", s_videoBasePath.c_str()); + if (FileUtil::directoryExists(testPath)) + { + s_scriptBasePath = testPath; + return; + } + + // Last resort: look for DCSS files loose alongside the OGVs. + // This rarely works but costs nothing to try, and lets a modder + // hand-edit a single cutscene without making a new directory. + s_scriptBasePath = s_videoBasePath; + } + + // ------------------------------------------------------------------ + // Subtitle path detection + // ------------------------------------------------------------------ + // + // The remaster ships SRT files either in a dedicated Subtitles/ + // subdirectory (rare) or loose alongside the OGVs (typical). Check + // dedicated first, fall back to loose. + static void detectSubtitlePath() + { + if (s_videoBasePath.empty()) { return; } + + char testPath[TFE_MAX_PATH]; + snprintf(testPath, TFE_MAX_PATH, "%sSubtitles/", s_videoBasePath.c_str()); + if (FileUtil::directoryExists(testPath)) + { + s_subtitleBasePath = testPath; + return; + } + + s_subtitleBasePath = s_videoBasePath; + } + + // ------------------------------------------------------------------ + // Public API + // ------------------------------------------------------------------ + + void remasterCutscenes_init() + { + // Idempotent. cutscene_init might be called multiple times (e.g. + // if the user reloads the game without restarting TFE), and we + // only want to do path detection once. + if (s_initialized) { return; } + s_initialized = true; + s_available = false; + + if (detectVideoPath()) + { + s_available = true; + detectScriptPath(); + detectSubtitlePath(); + TFE_System::logWrite(LOG_MSG, "Remaster", "Remaster OGV cutscene directory found."); + } + else + { + // This is the common case for people playing stock DOS Dark + // Forces without the remaster. Not an error - just means the + // LFD path stays in charge. + TFE_System::logWrite(LOG_MSG, "Remaster", "No remaster cutscene directory found; using original LFD cutscenes."); + } + } + + bool remasterCutscenes_available() + { + return s_available; + } + + // ------------------------------------------------------------------ + // Per-scene lookups + // ------------------------------------------------------------------ + // + // Each returns a pointer to one of our static buffers, or nullptr on + // miss. These are called per-frame at the start of a cutscene (not + // inside the hot loop), so performance isn't critical; readability + // wins. + + const char* remasterCutscenes_getVideoPath(const CutsceneState* scene) + { + if (!s_available || !scene) { return nullptr; } + + std::string baseName = sceneBaseName(scene); + if (baseName.empty()) { return nullptr; } + + // Try the language-specific variant first. The remaster only + // localizes videos that have baked-in text (notably logo.ogv + // which shows opening credits in English / German / etc.). Most + // cutscenes are language-neutral and only the base file exists. + const TFE_Settings_A11y* a11y = TFE_Settings::getA11ySettings(); + const char* lang = a11y->language.c_str(); + if (lang && lang[0]) + { + snprintf(s_videoPathResult, TFE_MAX_PATH, "%s%s_%s.ogv", + s_videoBasePath.c_str(), baseName.c_str(), lang); + if (FileUtil::exists(s_videoPathResult)) { return s_videoPathResult; } + } + + // Fall back to the default (no language suffix). + snprintf(s_videoPathResult, TFE_MAX_PATH, "%s%s.ogv", s_videoBasePath.c_str(), baseName.c_str()); + if (FileUtil::exists(s_videoPathResult)) { return s_videoPathResult; } + + // No OGV for this scene. The caller will fall back to the LFD + // FILM path. + return nullptr; + } + + const char* remasterCutscenes_getDcssPath(const CutsceneState* scene) + { + if (!s_available || !scene || s_scriptBasePath.empty()) { return nullptr; } + + std::string baseName = sceneBaseName(scene); + if (baseName.empty()) { return nullptr; } + + // DCSS files aren't localized - they're pure timing data. One + // file per scene, used regardless of language. + snprintf(s_scriptPathResult, TFE_MAX_PATH, "%s%s.dcss", s_scriptBasePath.c_str(), baseName.c_str()); + if (FileUtil::exists(s_scriptPathResult)) + { + return s_scriptPathResult; + } + return nullptr; + } + + const char* remasterCutscenes_getSubtitlePath(const CutsceneState* scene) + { + if (!s_available || !scene || s_subtitleBasePath.empty()) { return nullptr; } + + std::string baseName = sceneBaseName(scene); + if (baseName.empty()) { return nullptr; } + + const TFE_Settings_A11y* a11y = TFE_Settings::getA11ySettings(); + const char* lang = a11y->language.c_str(); + + // Lookup order for subtitles (most specific -> most generic): + // 1. _.srt (remaster convention, underscore) + // 2. ..srt (legacy TFE users who named files + // differently before we matched the + // remaster's convention) + // 3. .srt (default, usually English) + if (lang && lang[0]) + { + snprintf(s_subtitlePathResult, TFE_MAX_PATH, "%s%s_%s.srt", + s_subtitleBasePath.c_str(), baseName.c_str(), lang); + if (FileUtil::exists(s_subtitlePathResult)) { return s_subtitlePathResult; } + + snprintf(s_subtitlePathResult, TFE_MAX_PATH, "%s%s.%s.srt", + s_subtitleBasePath.c_str(), baseName.c_str(), lang); + if (FileUtil::exists(s_subtitlePathResult)) { return s_subtitlePathResult; } + } + + snprintf(s_subtitlePathResult, TFE_MAX_PATH, "%s%s.srt", + s_subtitleBasePath.c_str(), baseName.c_str()); + if (FileUtil::exists(s_subtitlePathResult)) { return s_subtitlePathResult; } + + return nullptr; + } + + // Called from the settings UI or test harness to point at a + // specific movies/ directory. Bypasses the priority-chain discovery + // in detectVideoPath() entirely. + void remasterCutscenes_setCustomPath(const char* path) + { + if (!path || !path[0]) + { + // Empty path = "turn off the remaster path entirely and go + // back to LFD." Reset all cached state. + s_videoBasePath.clear(); + s_scriptBasePath.clear(); + s_subtitleBasePath.clear(); + s_available = false; + return; + } + + s_videoBasePath = path; + if (s_videoBasePath.back() != '/' && s_videoBasePath.back() != '\\') + { + s_videoBasePath += '/'; + } + + s_available = FileUtil::directoryExists(s_videoBasePath.c_str()); + if (s_available) + { + // Re-detect scripts and subtitles relative to the new base. + detectScriptPath(); + detectSubtitlePath(); + } + } +} diff --git a/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.h b/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.h new file mode 100644 index 000000000..1a3fbf8d1 --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.h @@ -0,0 +1,52 @@ +#pragma once +//============================================================================ +// Remastered cutscene file resolution +//============================================================================ +// +// When Dark Forces wants to play cutscene N, the LFD-based path reads +// cutscene.lst to find the FILM archive and scene name, then plays the +// FILM's animation frames. The remastered path hooks in at the same point +// but resolves .ogv / .dcss / .srt on disk instead. +// +// This module is responsible for: +// +// 1. Finding WHERE the remaster's cutscene files live. There are several +// plausible locations (Steam install, GOG install, user-configured +// custom path, TFE program dir) and we try each in a defined order. +// +// 2. Translating a CutsceneState* (from cutscene.lst) into a concrete +// file path, with localized variants where they exist. +// +// The actual video playback / cue dispatch lives in cutscene.cpp; this +// module just answers "where's the file?". +// +// Keyed on scene->scene (the scene name), not the archive name. For stock +// Dark Forces data those are the same (ARCFLY.LFD -> "arcfly"), but the +// remaster keys on scene name and modders may reuse an archive across +// multiple scenes, so scene name is the right key. +// +#include + +struct CutsceneState; + +namespace TFE_DarkForces +{ + // Called once from cutscene_init(). Probes for the cutscene directory; + // after this, remasterCutscenes_available() returns the result. + void remasterCutscenes_init(); + + // True if we found a usable remaster install. Returns false after init + // if no candidate directory contained a "movies/" subdirectory. + bool remasterCutscenes_available(); + + // Returns a pointer to a static buffer containing the path, or nullptr + // if the file doesn't exist. The buffer is reused across calls, so + // don't hold the pointer past the next lookup. + const char* remasterCutscenes_getVideoPath(const CutsceneState* scene); + const char* remasterCutscenes_getDcssPath(const CutsceneState* scene); + const char* remasterCutscenes_getSubtitlePath(const CutsceneState* scene); + + // Manually override the base path (typically from the settings UI). + // Passing empty/null disables the remaster path entirely. + void remasterCutscenes_setCustomPath(const char* path); +} diff --git a/TheForceEngine/TFE_DarkForces/Remaster/srtParser.cpp b/TheForceEngine/TFE_DarkForces/Remaster/srtParser.cpp new file mode 100644 index 000000000..773831216 --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/srtParser.cpp @@ -0,0 +1,171 @@ +#include "srtParser.h" +#include +#include +#include +#include +#include + +namespace TFE_DarkForces +{ + // HH:MM:SS,mmm -> seconds + static bool parseTimestamp(const char* str, f64& outSeconds) + { + s32 hours, minutes, seconds, millis; + if (sscanf(str, "%d:%d:%d,%d", &hours, &minutes, &seconds, &millis) != 4) + { + // Some SRT files use period instead of comma. + if (sscanf(str, "%d:%d:%d.%d", &hours, &minutes, &seconds, &millis) != 4) + { + return false; + } + } + outSeconds = hours * 3600.0 + minutes * 60.0 + seconds + millis / 1000.0; + return true; + } + + static const char* skipWhitespace(const char* p, const char* end) + { + while (p < end && (*p == ' ' || *p == '\t')) + { + p++; + } + return p; + } + + static const char* readLine(const char* buffer, size_t size, size_t& pos, size_t& lineLen) + { + if (pos >= size) { return nullptr; } + const char* start = buffer + pos; + const char* end = buffer + size; + const char* p = start; + + while (p < end && *p != '\n' && *p != '\r') + { + p++; + } + lineLen = (size_t)(p - start); + + if (p < end && *p == '\r') { p++; } + if (p < end && *p == '\n') { p++; } + pos = (size_t)(p - buffer); + + return start; + } + + bool srt_parse(const char* buffer, size_t size, std::vector& entries) + { + entries.clear(); + if (!buffer || size == 0) { return false; } + + // Skip UTF-8 BOM. + size_t pos = 0; + if (size >= 3 && (u8)buffer[0] == 0xEF && (u8)buffer[1] == 0xBB && (u8)buffer[2] == 0xBF) + { + pos = 3; + } + + while (pos < size) + { + const char* line; + size_t lineLen; + do + { + line = readLine(buffer, size, pos, lineLen); + if (!line) { return !entries.empty(); } + } while (lineLen == 0); + + SrtEntry entry = {}; + entry.index = atoi(line); + if (entry.index <= 0) { continue; } + + line = readLine(buffer, size, pos, lineLen); + if (!line || lineLen == 0) { break; } + + char startTs[32] = {}; + char endTs[32] = {}; + const char* arrow = strstr(line, "-->"); + if (!arrow || arrow < line) { continue; } + + size_t startLen = (size_t)(arrow - line); + if (startLen > 31) startLen = 31; + memcpy(startTs, line, startLen); + startTs[startLen] = 0; + + const char* endStart = arrow + 3; + const char* lineEnd = line + lineLen; + endStart = skipWhitespace(endStart, lineEnd); + size_t endLen = (size_t)(lineEnd - endStart); + if (endLen > 31) endLen = 31; + memcpy(endTs, endStart, endLen); + endTs[endLen] = 0; + + if (!parseTimestamp(startTs, entry.startTime)) { continue; } + if (!parseTimestamp(endTs, entry.endTime)) { continue; } + + entry.text.clear(); + while (pos < size) + { + line = readLine(buffer, size, pos, lineLen); + if (!line || lineLen == 0) { break; } + + if (!entry.text.empty()) { entry.text += '\n'; } + entry.text.append(line, lineLen); + } + + if (!entry.text.empty()) + { + entries.push_back(entry); + } + } + + return !entries.empty(); + } + + bool srt_loadFromFile(const char* path, std::vector& entries) + { + FileStream file; + if (!file.open(path, Stream::MODE_READ)) + { + TFE_System::logWrite(LOG_WARNING, "SrtParser", "Cannot open SRT file: %s", path); + return false; + } + + size_t size = file.getSize(); + if (size == 0) + { + file.close(); + return false; + } + + char* buffer = (char*)malloc(size); + if (!buffer) + { + file.close(); + return false; + } + + file.readBuffer(buffer, (u32)size); + file.close(); + + bool result = srt_parse(buffer, size, entries); + free(buffer); + + if (result) + { + TFE_System::logWrite(LOG_MSG, "SrtParser", "Loaded %zu subtitle entries from %s", entries.size(), path); + } + return result; + } + + const SrtEntry* srt_getActiveEntry(const std::vector& entries, f64 timeInSeconds) + { + for (size_t i = 0; i < entries.size(); i++) + { + if (timeInSeconds >= entries[i].startTime && timeInSeconds < entries[i].endTime) + { + return &entries[i]; + } + } + return nullptr; + } +} diff --git a/TheForceEngine/TFE_DarkForces/Remaster/srtParser.h b/TheForceEngine/TFE_DarkForces/Remaster/srtParser.h new file mode 100644 index 000000000..bc0f7adb2 --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/srtParser.h @@ -0,0 +1,21 @@ +#pragma once +// SubRip (.srt) subtitle parser for OGV cutscenes. +#include +#include +#include + +namespace TFE_DarkForces +{ + struct SrtEntry + { + s32 index; + f64 startTime; // seconds + f64 endTime; // seconds + std::string text; + }; + + bool srt_parse(const char* buffer, size_t size, std::vector& entries); + bool srt_loadFromFile(const char* path, std::vector& entries); + // Returns the subtitle active at the given time, or nullptr. + const SrtEntry* srt_getActiveEntry(const std::vector& entries, f64 timeInSeconds); +} diff --git a/TheForceEngine/TFE_FrontEndUI/frontEndUi.cpp b/TheForceEngine/TFE_FrontEndUI/frontEndUi.cpp index 31a447e5d..71594a52e 100644 --- a/TheForceEngine/TFE_FrontEndUI/frontEndUi.cpp +++ b/TheForceEngine/TFE_FrontEndUI/frontEndUi.cpp @@ -1195,6 +1195,15 @@ namespace TFE_FrontEndUI gameSettings->df_disableFightMusic = disableFightMusic; } +#ifdef ENABLE_OGV_CUTSCENES + bool enableRemasterCutscenes = gameSettings->df_enableRemasterCutscenes; + if (ImGui::Checkbox("Use Remaster Cutscenes", &enableRemasterCutscenes)) + { + gameSettings->df_enableRemasterCutscenes = enableRemasterCutscenes; + } + Tooltip("Play remastered video cutscenes instead of the original animations when available."); +#endif + bool enableAutoaim = gameSettings->df_enableAutoaim; if (ImGui::Checkbox("Enable Autoaim", &enableAutoaim)) { diff --git a/TheForceEngine/TFE_RenderBackend/Win32OpenGL/renderBackend.cpp b/TheForceEngine/TFE_RenderBackend/Win32OpenGL/renderBackend.cpp index a364e36bb..76d67b6f3 100644 --- a/TheForceEngine/TFE_RenderBackend/Win32OpenGL/renderBackend.cpp +++ b/TheForceEngine/TFE_RenderBackend/Win32OpenGL/renderBackend.cpp @@ -58,6 +58,7 @@ namespace TFE_RenderBackend static bool s_gpuColorConvert = false; static bool s_useRenderTarget = false; static bool s_bloomEnable = false; + static bool s_skipDisplayAndClear = false; static DisplayMode s_displayMode; static f32 s_clearColor[4] = { 0.0f, 0.0f, 0.0f, 1.0f }; static u32 s_rtWidth, s_rtHeight; @@ -316,10 +317,24 @@ namespace TFE_RenderBackend memcpy(s_clearColor, color, sizeof(f32) * 4); } + void setSkipDisplayAndClear(bool skip) + { + s_skipDisplayAndClear = skip; + } + + bool getSkipDisplayAndClear() + { + return s_skipDisplayAndClear; + } + void swap(bool blitVirtualDisplay) { - // Blit the texture or render target to the screen. - if (blitVirtualDisplay) { drawVirtualDisplay(); } + // If an external renderer (e.g. OGV player) already drew to the backbuffer, skip. + if (s_skipDisplayAndClear) + { + s_skipDisplayAndClear = false; + } + else if (blitVirtualDisplay) { drawVirtualDisplay(); } else { glClear(GL_COLOR_BUFFER_BIT); } // Handle the UI. diff --git a/TheForceEngine/TFE_RenderBackend/renderBackend.h b/TheForceEngine/TFE_RenderBackend/renderBackend.h index cdfc30ef9..5c55843cb 100644 --- a/TheForceEngine/TFE_RenderBackend/renderBackend.h +++ b/TheForceEngine/TFE_RenderBackend/renderBackend.h @@ -98,6 +98,8 @@ namespace TFE_RenderBackend void setClearColor(const f32* color); void swap(bool blitVirtualDisplay); + void setSkipDisplayAndClear(bool skip); + bool getSkipDisplayAndClear(); void queueScreenshot(const char* screenshotPath); void startGifRecording(const char* path, bool skipCountdown = false); void stopGifRecording(); diff --git a/TheForceEngine/TFE_Settings/settings.cpp b/TheForceEngine/TFE_Settings/settings.cpp index f9f2ad51b..018f98a4d 100644 --- a/TheForceEngine/TFE_Settings/settings.cpp +++ b/TheForceEngine/TFE_Settings/settings.cpp @@ -581,6 +581,10 @@ namespace TFE_Settings writeKeyValue_Bool(settings, "centerHudPos", s_gameSettings.df_centerHudPosition); writeKeyValue_Bool(settings, "df_showMapSecrets", s_gameSettings.df_showMapSecrets); writeKeyValue_Bool(settings, "df_showMapObjects", s_gameSettings.df_showMapObjects); +#ifdef ENABLE_OGV_CUTSCENES + writeKeyValue_Bool(settings, "df_enableRemasterCutscenes", s_gameSettings.df_enableRemasterCutscenes); + writeKeyValue_String(settings, "df_remasterCutscenesPath", s_gameSettings.df_remasterCutscenesPath); +#endif } void writePerGameSettings(FileStream& settings) @@ -1269,7 +1273,18 @@ namespace TFE_Settings else if (strcasecmp("df_showMapObjects", key) == 0) { s_gameSettings.df_showMapObjects = parseBool(value); - } + } +#ifdef ENABLE_OGV_CUTSCENES + else if (strcasecmp("df_enableRemasterCutscenes", key) == 0) + { + s_gameSettings.df_enableRemasterCutscenes = parseBool(value); + } + else if (strcasecmp("df_remasterCutscenesPath", key) == 0) + { + strncpy(s_gameSettings.df_remasterCutscenesPath, value, TFE_MAX_PATH - 1); + s_gameSettings.df_remasterCutscenesPath[TFE_MAX_PATH - 1] = 0; + } +#endif } void parseOutlawsSettings(const char* key, const char* value) diff --git a/TheForceEngine/TFE_Settings/settings.h b/TheForceEngine/TFE_Settings/settings.h index a2e2bb788..eb20edabb 100644 --- a/TheForceEngine/TFE_Settings/settings.h +++ b/TheForceEngine/TFE_Settings/settings.h @@ -238,6 +238,10 @@ struct TFE_Settings_Game s32 df_playbackFrameRate = 2; // Playback Framerate value bool df_showKeyUsed = true; // Show a message when a key is used. PitchLimit df_pitchLimit = PITCH_VANILLA_PLUS; +#ifdef ENABLE_OGV_CUTSCENES + bool df_enableRemasterCutscenes = true; // Use remastered OGV cutscenes when available. + char df_remasterCutscenesPath[TFE_MAX_PATH] = ""; // Custom path to OGV cutscene files (empty = auto-detect). +#endif }; struct TFE_Settings_System diff --git a/TheForceEngine/TheForceEngine.vcxproj b/TheForceEngine/TheForceEngine.vcxproj index f511b487d..7da966dfe 100644 --- a/TheForceEngine/TheForceEngine.vcxproj +++ b/TheForceEngine/TheForceEngine.vcxproj @@ -131,8 +131,8 @@ true - $(ProjectDir);$(ProjectDir)\TFE_ForceScript\Angelscript\angelscript\include;$(ProjectDir)\TFE_ForceScript\Angelscript\add_on;$(ProjectDir)\sdl2_win32\include;$(IncludePath) - $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(LibraryPath) + $(ProjectDir);$(ProjectDir)\TFE_ForceScript\Angelscript\angelscript\include;$(ProjectDir)\TFE_ForceScript\Angelscript\add_on;$(ProjectDir)\sdl2_win32\include;$(ProjectDir)\ogg_theora_win32\include;$(IncludePath) + $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(ProjectDir)\ogg_theora_win32\lib\x64;$(LibraryPath) false @@ -145,18 +145,18 @@ false - $(ProjectDir);$(ProjectDir)\TFE_ForceScript\Angelscript\angelscript\include;$(ProjectDir)\TFE_ForceScript\Angelscript\add_on;$(ProjectDir)\sdl2_win32\include;$(IncludePath) - $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(LibraryPath) + $(ProjectDir);$(ProjectDir)\TFE_ForceScript\Angelscript\angelscript\include;$(ProjectDir)\TFE_ForceScript\Angelscript\add_on;$(ProjectDir)\sdl2_win32\include;$(ProjectDir)\ogg_theora_win32\include;$(IncludePath) + $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(ProjectDir)\ogg_theora_win32\lib\x64;$(LibraryPath) false - $(ProjectDir);$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\angelscript\include;$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\add_on;$(ProjectDir)\devILx64\include;$(ProjectDir)\sdl2_win32\include;$(IncludePath) - $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(ProjectDir)\devILx64\lib\x64\Release;$(LibraryPath) + $(ProjectDir);$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\angelscript\include;$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\add_on;$(ProjectDir)\devILx64\include;$(ProjectDir)\sdl2_win32\include;$(ProjectDir)\ogg_theora_win32\include;$(IncludePath) + $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(ProjectDir)\devILx64\lib\x64\Release;$(ProjectDir)\ogg_theora_win32\lib\x64;$(LibraryPath) false - $(ProjectDir);$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\angelscript\include;$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\add_on;$(ProjectDir)\devILx64\include;$(ProjectDir)\sdl2_win32\include;$(IncludePath) - $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(ProjectDir)\devILx64\lib\x64\Release;$(LibraryPath) + $(ProjectDir);$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\angelscript\include;$(ProjectDir)\TFE_ScriptSystem\AngelScript\sdk\add_on;$(ProjectDir)\devILx64\include;$(ProjectDir)\sdl2_win32\include;$(ProjectDir)\ogg_theora_win32\include;$(IncludePath) + $(ProjectDir)\sdl2_win32\lib\x64;$(ProjectDir)\lib;$(ProjectDir)\devILx64\lib\x64\Release;$(ProjectDir)\ogg_theora_win32\lib\x64;$(LibraryPath) @@ -178,14 +178,14 @@ Level3 Disabled true - _DEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;BUILD_SYSMIDI;BUILD_EDITOR;WIN32_OPENGL;__WINDOWS_MM__;ASMJIT_EMBED;ASMJIT_STATIC;ASMJIT_NO_FOREIGN;%(PreprocessorDefinitions) + _DEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;BUILD_SYSMIDI;BUILD_EDITOR;WIN32_OPENGL;__WINDOWS_MM__;ASMJIT_EMBED;ASMJIT_STATIC;ASMJIT_NO_FOREIGN;ENABLE_OGV_CUTSCENES;%(PreprocessorDefinitions) true ProgramDatabase Windows true - %(AdditionalDependencies) + ogg.lib;theora.lib;theoradec.lib;vorbis.lib;vorbisfile.lib;%(AdditionalDependencies) @@ -257,7 +257,7 @@ true true true - NDEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;BUILD_SYSMIDI;BUILD_EDITOR;WIN32_OPENGL;__WINDOWS_MM__;ASMJIT_EMBED;ASMJIT_STATIC;ASMJIT_NO_FOREIGN;%(PreprocessorDefinitions) + NDEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;BUILD_SYSMIDI;BUILD_EDITOR;WIN32_OPENGL;__WINDOWS_MM__;ASMJIT_EMBED;ASMJIT_STATIC;ASMJIT_NO_FOREIGN;ENABLE_OGV_CUTSCENES;%(PreprocessorDefinitions) true Fast @@ -266,7 +266,7 @@ true true true - %(AdditionalDependencies) + ogg.lib;theora.lib;theoradec.lib;vorbis.lib;vorbisfile.lib;%(AdditionalDependencies) PerMonitorHighDPIAware @@ -280,7 +280,7 @@ true true true - NDEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;WIN32_OPENGL;__WINDOWS_MM__;ASMJIT_EMBED;ASMJIT_STATIC;ASMJIT_NO_FOREIGN;%(PreprocessorDefinitions) + NDEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;WIN32_OPENGL;__WINDOWS_MM__;ASMJIT_EMBED;ASMJIT_STATIC;ASMJIT_NO_FOREIGN;ENABLE_OGV_CUTSCENES;%(PreprocessorDefinitions) true Fast @@ -289,7 +289,7 @@ true true true - %(AdditionalDependencies) + ogg.lib;theora.lib;theoradec.lib;vorbis.lib;vorbisfile.lib;%(AdditionalDependencies) @( @@ -310,7 +310,7 @@ echo ^)"; true true true - NDEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;WIN32_OPENGL;__WINDOWS_MM__;%(PreprocessorDefinitions) + NDEBUG;_WINDOWS;_CRT_SECURE_NO_WARNINGS;GLEW_STATIC;WIN32_OPENGL;__WINDOWS_MM__;ENABLE_OGV_CUTSCENES;%(PreprocessorDefinitions) true @@ -318,7 +318,7 @@ echo ^)"; true true true - %(AdditionalDependencies) + ogg.lib;theora.lib;theoradec.lib;vorbis.lib;vorbisfile.lib;%(AdditionalDependencies) PerMonitorHighDPIAware @@ -431,6 +431,10 @@ echo ^)"; + + + + @@ -849,6 +853,10 @@ echo ^)"; + + + +