From 2ca3fc342533b7dddcec6332aa51f5c42f8e7384 Mon Sep 17 00:00:00 2001 From: elliotttate Date: Sun, 1 Feb 2026 13:22:51 -0500 Subject: [PATCH 1/4] Add remastered OGV cutscene support Play Nightdive remaster's Ogg Theora video cutscenes when available, falling back to original LFD/FILM cutscenes otherwise. Uses libtheora for video decoding, libvorbis for audio, and a YUV->RGB GPU shader for rendering. Includes SRT subtitle support, auto-detection of remaster install paths, and a settings toggle. Gated behind ENABLE_OGV_CUTSCENES compile flag. --- CMakeLists.txt | 14 +- TheForceEngine/Shaders/yuv2rgb.frag | 24 + TheForceEngine/Shaders/yuv2rgb.vert | 11 + TheForceEngine/TFE_Audio/audioSystem.cpp | 19 +- TheForceEngine/TFE_Audio/audioSystem.h | 4 + .../TFE_DarkForces/Landru/cutscene.cpp | 143 +++- .../TFE_DarkForces/Remaster/ogvPlayer.cpp | 725 ++++++++++++++++++ .../TFE_DarkForces/Remaster/ogvPlayer.h | 23 + .../Remaster/remasterCutscenes.cpp | 229 ++++++ .../Remaster/remasterCutscenes.h | 19 + .../TFE_DarkForces/Remaster/srtParser.cpp | 171 +++++ .../TFE_DarkForces/Remaster/srtParser.h | 21 + TheForceEngine/TFE_FrontEndUI/frontEndUi.cpp | 9 + .../Win32OpenGL/renderBackend.cpp | 19 +- .../TFE_RenderBackend/renderBackend.h | 2 + TheForceEngine/TFE_Settings/settings.cpp | 17 +- TheForceEngine/TFE_Settings/settings.h | 4 + TheForceEngine/TheForceEngine.vcxproj | 38 +- 18 files changed, 1468 insertions(+), 24 deletions(-) create mode 100644 TheForceEngine/Shaders/yuv2rgb.frag create mode 100644 TheForceEngine/Shaders/yuv2rgb.vert create mode 100644 TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.cpp create mode 100644 TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.h create mode 100644 TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.cpp create mode 100644 TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.h create mode 100644 TheForceEngine/TFE_DarkForces/Remaster/srtParser.cpp create mode 100644 TheForceEngine/TFE_DarkForces/Remaster/srtParser.h 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/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..388dc6d03 100644 --- a/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp +++ b/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp @@ -11,6 +11,14 @@ #include #include #include +#ifdef ENABLE_OGV_CUTSCENES +#include +#include +#include +#include +#include +#include "lmusic.h" +#endif using namespace TFE_Jedi; @@ -23,12 +31,125 @@ namespace TFE_DarkForces s32 s_musicVolume = 0; s32 s_enabled = 1; +#ifdef ENABLE_OGV_CUTSCENES + static bool s_ogvPlaying = false; + static std::vector s_ogvSubtitles; +#endif + void cutscene_init(CutsceneState* cutsceneList) { s_playSeq = cutsceneList; s_playing = JFALSE; +#ifdef ENABLE_OGV_CUTSCENES + remasterCutscenes_init(); +#endif + } + +#ifdef ENABLE_OGV_CUTSCENES + // Look up a scene by ID in the cutscene list. + 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; + } + + // Try the remastered OGV version of a cutscene; returns false to fall back to LFD. + static bool tryPlayOgvCutscene(s32 sceneId) + { + 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; } + + const char* videoPath = remasterCutscenes_getVideoPath(scene); + if (!videoPath) { return false; } + + if (!TFE_OgvPlayer::open(videoPath)) + { + TFE_System::logWrite(LOG_WARNING, "Cutscene", "Failed to open OGV file: %s, falling back to LFD.", videoPath); + return false; + } + + // Load subtitles if captions are on. + s_ogvSubtitles.clear(); + if (TFE_A11Y::cutsceneCaptionsEnabled()) + { + const char* srtPath = remasterCutscenes_getSubtitlePath(scene); + if (srtPath) + { + srt_loadFromFile(srtPath, s_ogvSubtitles); + } + } + + // Start the MIDI music track for this cutscene. + if (scene->music > 0) + { + lmusic_setSequence(scene->music); + } + + s_ogvPlaying = true; + TFE_System::logWrite(LOG_MSG, "Cutscene", "Playing remastered OGV cutscene for scene %d (%s).", sceneId, scene->archive); + return true; } + static JBool ogvCutscene_update() + { + // Skip on ESC/Enter/Space (ignore Alt+Enter which toggles fullscreen). + 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)) + { + TFE_OgvPlayer::close(); + s_ogvPlaying = false; + s_ogvSubtitles.clear(); + TFE_A11Y::clearActiveCaptions(); + return JFALSE; + } + + if (!TFE_OgvPlayer::update()) + { + TFE_OgvPlayer::close(); + s_ogvPlaying = false; + s_ogvSubtitles.clear(); + TFE_A11Y::clearActiveCaptions(); + return JFALSE; + } + + // Update subtitle captions. + if (!s_ogvSubtitles.empty() && TFE_A11Y::cutsceneCaptionsEnabled()) + { + f64 time = TFE_OgvPlayer::getPlaybackTime(); + 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; + caption.microsecondsRemaining = (s64)((entry->endTime - time) * 1000000.0); + TFE_A11Y::clearActiveCaptions(); + TFE_A11Y::enqueueCaption(caption); + } + else + { + // No subtitle active right now. + TFE_A11Y::clearActiveCaptions(); + } + } + + return JTRUE; + } +#endif + JBool cutscene_play(s32 sceneId) { if (!s_enabled || !s_playSeq) { return JFALSE; } @@ -47,10 +168,20 @@ namespace TFE_DarkForces } } if (!found) return JFALSE; + +#ifdef ENABLE_OGV_CUTSCENES + // Prefer the remastered OGV if available. + if (tryPlayOgvCutscene(sceneId)) + { + s_playing = JTRUE; + return JTRUE; + } +#endif + // Re-initialize the canvas, so cutscenes run at the correct resolution even if it was changed for gameplay // (i.e. high resolution support). 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. s_playing = JTRUE; @@ -62,6 +193,14 @@ namespace TFE_DarkForces { 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; } @@ -95,4 +234,4 @@ namespace TFE_DarkForces { return s_musicVolume; } -} // TFE_DarkForces \ No newline at end of file +} // TFE_DarkForces diff --git a/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.cpp b/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.cpp new file mode 100644 index 000000000..bb0c2a3d8 --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.cpp @@ -0,0 +1,725 @@ +#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; + } + + f64 getPlaybackTime() + { + if (!s_playing) { return 0.0; } + return TFE_System::getTime() - s_playbackStart; + } + + 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..438df8723 --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.h @@ -0,0 +1,23 @@ +#pragma once +// OGV cutscene player - decodes Ogg Theora video with Vorbis audio +// and renders frames via GPU YUV->RGB conversion. +#include + +#ifdef ENABLE_OGV_CUTSCENES + +namespace TFE_OgvPlayer +{ + bool init(); + void shutdown(); + + bool open(const char* filepath); + void close(); + + // Decode and render the next frame. Returns false when playback ends. + bool update(); + + bool isPlaying(); + f64 getPlaybackTime(); +} + +#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..591819b86 --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.cpp @@ -0,0 +1,229 @@ +#include "remasterCutscenes.h" +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#endif +#include +#include +#include +#include + +namespace TFE_DarkForces +{ + static bool s_initialized = false; + static bool s_available = false; + static std::string s_videoBasePath; + static std::string s_subtitleBasePath; + static char s_videoPathResult[TFE_MAX_PATH]; + static char s_subtitlePathResult[TFE_MAX_PATH]; + + // "ARCFLY.LFD" -> "arcfly" + static std::string archiveToBaseName(const char* archive) + { + std::string name(archive); + for (size_t i = 0; i < name.size(); i++) + { + name[i] = (char)tolower((u8)name[i]); + } + size_t dot = name.rfind(".lfd"); + if (dot != std::string::npos) + { + name = name.substr(0, dot); + } + return name; + } + + static const char* s_subdirNames[] = { "movies/", "Cutscenes/" }; + static const int s_subdirCount = 2; + + 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::directoryExits(testPath)) + { + s_videoBasePath = testPath; + TFE_System::logWrite(LOG_MSG, "Remaster", "Found remaster cutscenes at: %s", testPath); + return true; + } + } + return false; + } + + static bool detectVideoPath() + { + // Custom path from settings. +#ifdef ENABLE_OGV_CUTSCENES + const TFE_Settings_Game* gameSettings = TFE_Settings::getGameSettings(); + if (gameSettings->df_remasterCutscenesPath[0]) + { + std::string custom = gameSettings->df_remasterCutscenesPath; + if (custom.back() != '/' && custom.back() != '\\') { custom += '/'; } + if (FileUtil::directoryExits(custom.c_str())) + { + s_videoBasePath = custom; + TFE_System::logWrite(LOG_MSG, "Remaster", "Using custom cutscene path: %s", custom.c_str()); + return true; + } + } +#endif + + // Remaster docs path. + if (TFE_Paths::hasPath(PATH_REMASTER_DOCS)) + { + if (tryBasePath(TFE_Paths::getPath(PATH_REMASTER_DOCS))) + return true; + } + + // Source data path. + const char* sourcePath = TFE_Settings::getGameHeader("Dark Forces")->sourcePath; + if (sourcePath && sourcePath[0]) + { + if (tryBasePath(sourcePath)) + return true; + } + + // Steam registry lookup (Windows). +#ifdef _WIN32 + { + 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; + } + // TM variant path. + 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 + + // Program directory. + if (tryBasePath(TFE_Paths::getPath(PATH_PROGRAM))) + return true; + + return false; + } + + 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::directoryExits(testPath)) + { + s_subtitleBasePath = testPath; + return; + } + + // Fall back to same directory as videos. + s_subtitleBasePath = s_videoBasePath; + } + + void remasterCutscenes_init() + { + if (s_initialized) { return; } + s_initialized = true; + s_available = false; + + if (detectVideoPath()) + { + s_available = true; + detectSubtitlePath(); + TFE_System::logWrite(LOG_MSG, "Remaster", "Remaster OGV cutscene directory found."); + } + else + { + TFE_System::logWrite(LOG_MSG, "Remaster", "No remaster cutscene directory found; using original LFD cutscenes."); + } + } + + bool remasterCutscenes_available() + { + return s_available; + } + + const char* remasterCutscenes_getVideoPath(const CutsceneState* scene) + { + if (!s_available || !scene) { return nullptr; } + + std::string baseName = archiveToBaseName(scene->archive); + if (baseName.empty()) { return nullptr; } + + snprintf(s_videoPathResult, TFE_MAX_PATH, "%s%s.ogv", s_videoBasePath.c_str(), baseName.c_str()); + if (FileUtil::exists(s_videoPathResult)) + { + return s_videoPathResult; + } + return nullptr; + } + + const char* remasterCutscenes_getSubtitlePath(const CutsceneState* scene) + { + if (!s_available || !scene || s_subtitleBasePath.empty()) { return nullptr; } + + std::string baseName = archiveToBaseName(scene->archive); + if (baseName.empty()) { return nullptr; } + + // Try language-specific subtitle first. + const TFE_Settings_A11y* a11y = TFE_Settings::getA11ySettings(); + snprintf(s_subtitlePathResult, TFE_MAX_PATH, "%s%s.%s.srt", + s_subtitleBasePath.c_str(), baseName.c_str(), a11y->language.c_str()); + if (FileUtil::exists(s_subtitlePathResult)) + { + return s_subtitlePathResult; + } + + // Fall back to default (no language suffix). + 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; + } + + void remasterCutscenes_setCustomPath(const char* path) + { + if (!path || !path[0]) + { + s_videoBasePath.clear(); + s_available = false; + return; + } + + s_videoBasePath = path; + if (s_videoBasePath.back() != '/' && s_videoBasePath.back() != '\\') + { + s_videoBasePath += '/'; + } + + s_available = FileUtil::directoryExits(s_videoBasePath.c_str()); + if (s_available) + { + detectSubtitlePath(); + } + } +} diff --git a/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.h b/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.h new file mode 100644 index 000000000..1b810bc59 --- /dev/null +++ b/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.h @@ -0,0 +1,19 @@ +#pragma once +// Detects remastered OGV cutscene files and maps CutsceneState archive +// names to video/subtitle paths (e.g. "ARCFLY.LFD" -> "arcfly.ogv"). +#include + +struct CutsceneState; + +namespace TFE_DarkForces +{ + void remasterCutscenes_init(); + bool remasterCutscenes_available(); + + // Maps a scene's archive name to its OGV path, or nullptr if not found. + const char* remasterCutscenes_getVideoPath(const CutsceneState* scene); + // Returns the SRT subtitle path for a scene (language-specific, then default). + const char* remasterCutscenes_getSubtitlePath(const CutsceneState* scene); + + 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 3bf7c9b25..413a23ea5 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 b6c7a0e09..469019af1 100644 --- a/TheForceEngine/TFE_Settings/settings.cpp +++ b/TheForceEngine/TFE_Settings/settings.cpp @@ -579,6 +579,10 @@ namespace TFE_Settings writeKeyValue_Bool(settings, "df_showKeyColors", s_gameSettings.df_showKeyColors); 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) @@ -1255,7 +1259,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 2542db228..48ab5a27c 100644 --- a/TheForceEngine/TFE_Settings/settings.h +++ b/TheForceEngine/TFE_Settings/settings.h @@ -236,6 +236,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..b362cd89b 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,9 @@ echo ^)"; + + + @@ -849,6 +852,9 @@ echo ^)"; + + + From 684f38cd868a5be0471543cf3351f1c1987e2fc7 Mon Sep 17 00:00:00 2001 From: elliotttate Date: Sun, 1 Feb 2026 18:43:07 -0500 Subject: [PATCH 2/4] OGV cutscenes: data-driven MIDI cue scheduling from original FILM data Replace the hardcoded cue timer with a pre-scan approach that extracts music cue points from the original FILM cutscene chain. Each FILM's CUST actor is loaded and ticked to capture its cue value and timestamp, then all times are scaled to match the OGV video duration. This ensures MIDI music transitions fire at the correct visual moments for all cutscenes, not just the intro. --- .../TFE_DarkForces/Landru/cutscene.cpp | 162 +++++++++++++++++- 1 file changed, 160 insertions(+), 2 deletions(-) diff --git a/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp b/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp index 388dc6d03..dfd48d9d6 100644 --- a/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp +++ b/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp @@ -17,7 +17,11 @@ #include #include #include +#include #include "lmusic.h" +#include "cutscene_film.h" +#include "lsound.h" +#include #endif using namespace TFE_Jedi; @@ -34,6 +38,19 @@ namespace TFE_DarkForces #ifdef ENABLE_OGV_CUTSCENES static bool s_ogvPlaying = false; static std::vector s_ogvSubtitles; + + // Pre-computed cue schedule: (ogvTime, cueValue) pairs. + struct OgvCueEntry { f64 ogvTime; s32 cueValue; }; + static std::vector s_ogvCueSchedule; + static s32 s_ogvNextCueIdx = 0; + + // Frame rate delay table (ticks at 240 Hz) indexed by (speed - 4). + // Duplicated from cutscene_player.cpp since it's a small constant table. + static const s32 c_ogvFrameRateDelay[] = + { + 42, 49, 40, 35, 31, 28, 25, 23, 20, 19, 17, 16, 15, 14, 13, 12, 12, + }; + enum { OGV_MIN_FPS = 4, OGV_MAX_FPS = 20, OGV_TICKS_PER_SEC = 240 }; #endif void cutscene_init(CutsceneState* cutsceneList) @@ -60,6 +77,124 @@ namespace TFE_DarkForces return nullptr; } + // Scan callback: captures the cue point value set by CUST actors. + static s32 s_scanCueValue = 0; + + static void ogv_scanCueCallback(LActor* actor, s32 time) + { + if (actor->var1 > 0) { s_scanCueValue = actor->var1; } + } + + static JBool ogv_scanLoadCallback(Film* film, FilmObject* obj) + { + if (obj->id == CF_FILE_ACTOR) + { + LActor* actor = (LActor*)obj->data; + if (actor->resType == CF_TYPE_CUSTOM_ACTOR) + { + lactor_setCallback(actor, ogv_scanCueCallback); + } + } + return JFALSE; + } + + static void ogvFilm_cleanup() + { + s_ogvCueSchedule.clear(); + s_ogvNextCueIdx = 0; + lmusic_stop(); + } + + // Pre-scan the entire FILM chain to build a cue schedule. + // Loads each FILM briefly, ticks frame 0 to capture the CUST cue value, + // records accumulated FILM time, then unloads. Finally scales all times + // to match OGV duration. + static void ogvFilm_buildCueSchedule(s32 startSceneId, f64 ogvDuration) + { + s_ogvCueSchedule.clear(); + s_ogvNextCueIdx = 0; + + struct RawCue { f64 filmTime; s32 cueValue; }; + std::vector rawCues; + f64 accumulatedTime = 0.0; + + lcanvas_init(320, 200); + lsystem_setAllocator(LALLOC_CUTSCENE); + + s32 sceneId = startSceneId; + while (sceneId != SCENE_EXIT) + { + CutsceneState* scene = findScene(sceneId); + if (!scene) { break; } + + FilePath path; + if (!TFE_Paths::getFilePath(scene->archive, &path)) { break; } + + Archive* lfd = new LfdArchive(); + if (!lfd->open(path.path)) + { + delete lfd; + break; + } + + TFE_Paths::addLocalArchiveToFront(lfd); + LRect rect; + lcanvas_getBounds(&rect); + + s_scanCueValue = 0; + Film* film = cutsceneFilm_load(scene->scene, &rect, 0, 0, 0, ogv_scanLoadCallback); + + if (film) + { + // Tick frame 0 to trigger the CUST actor callback. + cutsceneFilm_updateFilms(0); + cutsceneFilm_updateCallbacks(0); + lactor_updateCallbacks(0); + + if (s_scanCueValue > 0) + { + rawCues.push_back({ accumulatedTime, s_scanCueValue }); + } + + // Compute this scene's duration. + s32 speed = clamp((s32)scene->speed, (s32)OGV_MIN_FPS, (s32)OGV_MAX_FPS); + s32 tickDelay = c_ogvFrameRateDelay[speed - OGV_MIN_FPS]; + f64 secsPerCell = (f64)tickDelay / (f64)OGV_TICKS_PER_SEC; + accumulatedTime += film->cellCount * secsPerCell; + + cutsceneFilm_remove(film); + cutsceneFilm_free(film); + } + + TFE_Paths::removeFirstArchive(); + delete lfd; + + sceneId = scene->nextId; + } + + lsystem_clearAllocator(LALLOC_CUTSCENE); + lsystem_setAllocator(LALLOC_PERSISTENT); + + // Scale FILM times to OGV duration. + // The OGV video has a short lead-in (~1s) before the FILM content begins, + // so we offset all cues after the first by this amount. + const f64 c_ogvLeadInOffset = 1.0; + f64 totalFilmTime = accumulatedTime; + f64 scale = (totalFilmTime > 0.0) ? (ogvDuration / totalFilmTime) : 1.0; + + for (const auto& raw : rawCues) + { + f64 ogvTime = raw.filmTime * scale; + if (ogvTime > 0.0) { ogvTime += c_ogvLeadInOffset; } + s_ogvCueSchedule.push_back({ ogvTime, raw.cueValue }); + TFE_System::logWrite(LOG_MSG, "Cutscene", "OGV cue schedule: cue %d at %.2fs (film=%.2fs, scale=%.3f)", + raw.cueValue, ogvTime, raw.filmTime, scale); + } + + TFE_System::logWrite(LOG_MSG, "Cutscene", "OGV cue schedule: %d cues, totalFilmTime=%.1fs, ogvDuration=%.1fs, scale=%.3f", + (s32)s_ogvCueSchedule.size(), totalFilmTime, ogvDuration, scale); + } + // Try the remastered OGV version of a cutscene; returns false to fall back to LFD. static bool tryPlayOgvCutscene(s32 sceneId) { @@ -90,10 +225,18 @@ namespace TFE_DarkForces } } - // Start the MIDI music track for this cutscene. + // Pre-scan the original FILM chain to build a cue schedule. + // Each scene's FILM has a CUST actor that sets a music cue point. + // We extract all cue values and their FILM timestamps, then scale + // to OGV duration so cues fire at the right visual moments. + s_ogvCueSchedule.clear(); + s_ogvNextCueIdx = 0; + if (scene->music > 0) { lmusic_setSequence(scene->music); + f64 ogvDuration = TFE_OgvPlayer::getDuration(); + ogvFilm_buildCueSchedule(sceneId, ogvDuration); } s_ogvPlaying = true; @@ -112,6 +255,7 @@ namespace TFE_DarkForces s_ogvPlaying = false; s_ogvSubtitles.clear(); TFE_A11Y::clearActiveCaptions(); + ogvFilm_cleanup(); return JFALSE; } @@ -121,9 +265,24 @@ namespace TFE_DarkForces s_ogvPlaying = false; s_ogvSubtitles.clear(); TFE_A11Y::clearActiveCaptions(); + ogvFilm_cleanup(); return JFALSE; } + // Fire cue points from the pre-computed schedule. + if (s_ogvNextCueIdx < (s32)s_ogvCueSchedule.size()) + { + f64 ogvTime = TFE_OgvPlayer::getPlaybackTime(); + while (s_ogvNextCueIdx < (s32)s_ogvCueSchedule.size() && + ogvTime >= s_ogvCueSchedule[s_ogvNextCueIdx].ogvTime) + { + s32 cue = s_ogvCueSchedule[s_ogvNextCueIdx].cueValue; + TFE_System::logWrite(LOG_MSG, "Cutscene", "OGV firing cue %d at OGV time %.2fs", cue, ogvTime); + lmusic_setCuePoint(cue); + s_ogvNextCueIdx++; + } + } + // Update subtitle captions. if (!s_ogvSubtitles.empty() && TFE_A11Y::cutsceneCaptionsEnabled()) { @@ -141,7 +300,6 @@ namespace TFE_DarkForces } else { - // No subtitle active right now. TFE_A11Y::clearActiveCaptions(); } } From 558bfe65551f9e35e4bcbf8497ae3eebb5929e50 Mon Sep 17 00:00:00 2001 From: elliotttate Date: Mon, 20 Apr 2026 16:23:23 -0400 Subject: [PATCH 3/4] OGV cutscenes: replace FILM pre-scan with DCSS scripts + docs The remaster's actual timing mechanism is a per-scene DCSS text script that fires seq/cue/musicvol directives against the video's clock. Our previous approach of pre-scanning LFD FILMs and scaling to OGV duration was approximate and fragile. This switches to a real DCSS parser that matches the remaster's format byte-for-byte (including its parse quirks), and dispatches cues against the video's intrinsic clock for sub-frame accuracy. Code: - New dcssParser.{h,cpp}: tolerant SRT-like parser, sorted-on-load, '#'/'//' comments. All 13 stock .dcss files validated. - cutscene.cpp: dropped FILM pre-scan. OGV path now reads DCSS and dispatches via getVideoTime() for hitch-proof sync. musicvol now scales 100 = base (matches CUTSCENE.LST convention and the LFD path); previous /127 made every cutscene ~20% quiet. - remasterCutscenes: key lookups on scene->scene (matches remaster), added getDcssPath() with auto-detection by walking up from movies/. Locale-aware OGV and SRT lookup (logo_de.ogv, arcfly_de.srt, ...). - ogvPlayer: new getVideoTime() returning the intrinsic video clock. Docs in Documentation/markdown/remaster-cutscenes/: README, architecture, dcss-format, modding-guide, video-conversion (MP4 -> OGV pipeline tested end-to-end with a verified ffmpeg one-liner), troubleshooting. Measured timing drift: 0-33ms (<=1 frame at 30fps) over 1:53 of playback, bounded, no accumulation. --- .../markdown/remaster-cutscenes/README.md | 73 +++ .../remaster-cutscenes/architecture.md | 251 ++++++++ .../remaster-cutscenes/dcss-format.md | 247 ++++++++ .../remaster-cutscenes/modding-guide.md | 366 ++++++++++++ .../remaster-cutscenes/troubleshooting.md | 337 +++++++++++ .../remaster-cutscenes/video-conversion.md | 265 +++++++++ .../TFE_DarkForces/Landru/cutscene.cpp | 556 +++++++++++------- .../TFE_DarkForces/Remaster/dcssParser.cpp | 376 ++++++++++++ .../TFE_DarkForces/Remaster/dcssParser.h | 102 ++++ .../TFE_DarkForces/Remaster/ogvPlayer.cpp | 24 + .../TFE_DarkForces/Remaster/ogvPlayer.h | 41 +- .../Remaster/remasterCutscenes.cpp | 279 ++++++++- .../Remaster/remasterCutscenes.h | 41 +- TheForceEngine/TheForceEngine.vcxproj | 2 + 14 files changed, 2724 insertions(+), 236 deletions(-) create mode 100644 TheForceEngine/Documentation/markdown/remaster-cutscenes/README.md create mode 100644 TheForceEngine/Documentation/markdown/remaster-cutscenes/architecture.md create mode 100644 TheForceEngine/Documentation/markdown/remaster-cutscenes/dcss-format.md create mode 100644 TheForceEngine/Documentation/markdown/remaster-cutscenes/modding-guide.md create mode 100644 TheForceEngine/Documentation/markdown/remaster-cutscenes/troubleshooting.md create mode 100644 TheForceEngine/Documentation/markdown/remaster-cutscenes/video-conversion.md create mode 100644 TheForceEngine/TFE_DarkForces/Remaster/dcssParser.cpp create mode 100644 TheForceEngine/TFE_DarkForces/Remaster/dcssParser.h 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/TFE_DarkForces/Landru/cutscene.cpp b/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp index dfd48d9d6..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" @@ -15,44 +51,73 @@ #include #include #include +#include #include #include -#include #include "lmusic.h" -#include "cutscene_film.h" -#include "lsound.h" -#include #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; - static std::vector s_ogvSubtitles; - // Pre-computed cue schedule: (ogvTime, cueValue) pairs. - struct OgvCueEntry { f64 ogvTime; s32 cueValue; }; - static std::vector s_ogvCueSchedule; - static s32 s_ogvNextCueIdx = 0; + // 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; - // Frame rate delay table (ticks at 240 Hz) indexed by (speed - 4). - // Duplicated from cutscene_player.cpp since it's a small constant table. - static const s32 c_ogvFrameRateDelay[] = - { - 42, 49, 40, 35, 31, 28, 25, 23, 20, 19, 17, 16, 15, 14, 13, 12, 12, - }; - enum { OGV_MIN_FPS = 4, OGV_MAX_FPS = 20, OGV_TICKS_PER_SEC = 240 }; + // 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; @@ -63,141 +128,106 @@ namespace TFE_DarkForces } #ifdef ENABLE_OGV_CUTSCENES - // Look up a scene by ID in the cutscene list. + // ====================================================================== + // 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]; - } + if (s_playSeq[i].id == sceneId) { return &s_playSeq[i]; } } return nullptr; } - // Scan callback: captures the cue point value set by CUST actors. - static s32 s_scanCueValue = 0; - - static void ogv_scanCueCallback(LActor* actor, s32 time) + // 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) { - if (actor->var1 > 0) { s_scanCueValue = actor->var1; } + 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); } - static JBool ogv_scanLoadCallback(Film* film, FilmObject* obj) + // 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 (obj->id == CF_FILE_ACTOR) + #if DCSS_TIMING_TRACE { - LActor* actor = (LActor*)obj->data; - if (actor->resType == CF_TYPE_CUSTOM_ACTOR) - { - lactor_setCallback(actor, ogv_scanCueCallback); - } + 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); } - return JFALSE; - } + #endif + TFE_OgvPlayer::close(); + s_ogvPlaying = false; - static void ogvFilm_cleanup() - { - s_ogvCueSchedule.clear(); - s_ogvNextCueIdx = 0; - lmusic_stop(); - } - - // Pre-scan the entire FILM chain to build a cue schedule. - // Loads each FILM briefly, ticks frame 0 to capture the CUST cue value, - // records accumulated FILM time, then unloads. Finally scales all times - // to match OGV duration. - static void ogvFilm_buildCueSchedule(s32 startSceneId, f64 ogvDuration) - { - s_ogvCueSchedule.clear(); + // 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(); - struct RawCue { f64 filmTime; s32 cueValue; }; - std::vector rawCues; - f64 accumulatedTime = 0.0; - - lcanvas_init(320, 200); - lsystem_setAllocator(LALLOC_CUTSCENE); - - s32 sceneId = startSceneId; - while (sceneId != SCENE_EXIT) - { - CutsceneState* scene = findScene(sceneId); - if (!scene) { break; } - - FilePath path; - if (!TFE_Paths::getFilePath(scene->archive, &path)) { break; } - - Archive* lfd = new LfdArchive(); - if (!lfd->open(path.path)) - { - delete lfd; - break; - } - - TFE_Paths::addLocalArchiveToFront(lfd); - LRect rect; - lcanvas_getBounds(&rect); - - s_scanCueValue = 0; - Film* film = cutsceneFilm_load(scene->scene, &rect, 0, 0, 0, ogv_scanLoadCallback); - - if (film) - { - // Tick frame 0 to trigger the CUST actor callback. - cutsceneFilm_updateFilms(0); - cutsceneFilm_updateCallbacks(0); - lactor_updateCallbacks(0); - - if (s_scanCueValue > 0) - { - rawCues.push_back({ accumulatedTime, s_scanCueValue }); - } - - // Compute this scene's duration. - s32 speed = clamp((s32)scene->speed, (s32)OGV_MIN_FPS, (s32)OGV_MAX_FPS); - s32 tickDelay = c_ogvFrameRateDelay[speed - OGV_MIN_FPS]; - f64 secsPerCell = (f64)tickDelay / (f64)OGV_TICKS_PER_SEC; - accumulatedTime += film->cellCount * secsPerCell; - - cutsceneFilm_remove(film); - cutsceneFilm_free(film); - } - - TFE_Paths::removeFirstArchive(); - delete lfd; - - sceneId = scene->nextId; - } - - lsystem_clearAllocator(LALLOC_CUTSCENE); - lsystem_setAllocator(LALLOC_PERSISTENT); + // Match the remaster's teardown: setSequence(0) unloads all MIDI + // state and stops any in-flight notes. + lmusic_setSequence(0); - // Scale FILM times to OGV duration. - // The OGV video has a short lead-in (~1s) before the FILM content begins, - // so we offset all cues after the first by this amount. - const f64 c_ogvLeadInOffset = 1.0; - f64 totalFilmTime = accumulatedTime; - f64 scale = (totalFilmTime > 0.0) ? (ogvDuration / totalFilmTime) : 1.0; - - for (const auto& raw : rawCues) - { - f64 ogvTime = raw.filmTime * scale; - if (ogvTime > 0.0) { ogvTime += c_ogvLeadInOffset; } - s_ogvCueSchedule.push_back({ ogvTime, raw.cueValue }); - TFE_System::logWrite(LOG_MSG, "Cutscene", "OGV cue schedule: cue %d at %.2fs (film=%.2fs, scale=%.3f)", - raw.cueValue, ogvTime, raw.filmTime, scale); - } - - TFE_System::logWrite(LOG_MSG, "Cutscene", "OGV cue schedule: %d cues, totalFilmTime=%.1fs, ogvDuration=%.1fs, scale=%.3f", - (s32)s_ogvCueSchedule.size(), totalFilmTime, ogvDuration, scale); + // 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); } - // Try the remastered OGV version of a cutscene; returns false to fall back to LFD. + // ---------------------------------------------------------------------- + // 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; } @@ -205,130 +235,248 @@ namespace TFE_DarkForces 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; } - // Load subtitles if captions are on. + // 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); - } + if (srtPath) { srt_loadFromFile(srtPath, s_ogvSubtitles); } } - // Pre-scan the original FILM chain to build a cue schedule. - // Each scene's FILM has a CUST actor that sets a music cue point. - // We extract all cue values and their FILM timestamps, then scale - // to OGV duration so cues fire at the right visual moments. - s_ogvCueSchedule.clear(); + // Reset DCSS state before loading a fresh one. + s_ogvScript.entries.clear(); + s_ogvScript.creditsFlag = false; + s_ogvScript.openingCreditsFlag = false; s_ogvNextCueIdx = 0; - if (scene->music > 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); - f64 ogvDuration = TFE_OgvPlayer::getDuration(); - ogvFilm_buildCueSchedule(sceneId, ogvDuration); } s_ogvPlaying = true; - TFE_System::logWrite(LOG_MSG, "Cutscene", "Playing remastered OGV cutscene for scene %d (%s).", sceneId, scene->archive); + #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; } - static JBool ogvCutscene_update() + // ---------------------------------------------------------------------- + // 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() { - // Skip on ESC/Enter/Space (ignore Alt+Enter which toggles fullscreen). - 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)) + 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) { - TFE_OgvPlayer::close(); - s_ogvPlaying = false; - s_ogvSubtitles.clear(); - TFE_A11Y::clearActiveCaptions(); - ogvFilm_cleanup(); - return JFALSE; + 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++; } + } - if (!TFE_OgvPlayer::update()) + // 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_OgvPlayer::close(); - s_ogvPlaying = false; - s_ogvSubtitles.clear(); + 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(); - ogvFilm_cleanup(); - return JFALSE; + TFE_A11Y::enqueueCaption(caption); } + else + { + TFE_A11Y::clearActiveCaptions(); + } + } - // Fire cue points from the pre-computed schedule. - if (s_ogvNextCueIdx < (s32)s_ogvCueSchedule.size()) + // 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)) { - f64 ogvTime = TFE_OgvPlayer::getPlaybackTime(); - while (s_ogvNextCueIdx < (s32)s_ogvCueSchedule.size() && - ogvTime >= s_ogvCueSchedule[s_ogvNextCueIdx].ogvTime) - { - s32 cue = s_ogvCueSchedule[s_ogvNextCueIdx].cueValue; - TFE_System::logWrite(LOG_MSG, "Cutscene", "OGV firing cue %d at OGV time %.2fs", cue, ogvTime); - lmusic_setCuePoint(cue); - s_ogvNextCueIdx++; - } + ogvCutscene_teardown(); + return JFALSE; } - // Update subtitle captions. - if (!s_ogvSubtitles.empty() && TFE_A11Y::cutsceneCaptionsEnabled()) + // 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()) { - f64 time = TFE_OgvPlayer::getPlaybackTime(); - 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; - caption.microsecondsRemaining = (s64)((entry->endTime - time) * 1000000.0); - TFE_A11Y::clearActiveCaptions(); - TFE_A11Y::enqueueCaption(caption); - } - else - { - TFE_A11Y::clearActiveCaptions(); - } + 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; #ifdef ENABLE_OGV_CUTSCENES - // Prefer the remastered OGV if available. + // 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; @@ -336,17 +484,23 @@ namespace TFE_DarkForces } #endif - // Re-initialize the canvas, so cutscenes run at the correct resolution even if it was changed for gameplay - // (i.e. high resolution support). + // 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; } @@ -363,6 +517,10 @@ namespace TFE_DarkForces return s_playing; } + // ====================================================================== + // Legacy settings interface (unchanged from stock TFE) + // ====================================================================== + void cutscene_enable(s32 enable) { s_enabled = enable; 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 index bb0c2a3d8..fb1d696a7 100644 --- a/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.cpp +++ b/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.cpp @@ -317,12 +317,36 @@ namespace TFE_OgvPlayer 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; } diff --git a/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.h b/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.h index 438df8723..e875f0e2f 100644 --- a/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.h +++ b/TheForceEngine/TFE_DarkForces/Remaster/ogvPlayer.h @@ -1,23 +1,58 @@ #pragma once -// OGV cutscene player - decodes Ogg Theora video with Vorbis audio -// and renders frames via GPU YUV->RGB conversion. +//============================================================================ +// 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 and render the next frame. Returns false when playback ends. + // 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 index 591819b86..18ea3c9d1 100644 --- a/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.cpp +++ b/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.cpp @@ -12,34 +12,109 @@ #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]; - // "ARCFLY.LFD" -> "arcfly" - static std::string archiveToBaseName(const char* archive) + // ------------------------------------------------------------------ + // 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 name(archive); - for (size_t i = 0; i < name.size(); i++) + std::string out; + out.reserve(maxLen); + for (size_t i = 0; i < maxLen && src[i]; i++) { - name[i] = (char)tolower((u8)name[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); - } + 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]; @@ -56,14 +131,30 @@ namespace TFE_DarkForces 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() { - // Custom path from settings. #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::directoryExits(custom.c_str())) { @@ -71,17 +162,22 @@ namespace TFE_DarkForces 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 - // Remaster docs path. + // 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; } - // Source data path. + // 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]) { @@ -89,8 +185,10 @@ namespace TFE_DarkForces return true; } - // Steam registry lookup (Windows). #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( @@ -103,7 +201,6 @@ namespace TFE_DarkForces if (tryBasePath(remasterPath)) return true; } - // TM variant path. if (WindowsRegistry::getSteamPathFromRegistry( TFE_Settings::c_steamRemasterProductId[Game_Dark_Forces], TFE_Settings::c_steamRemasterTMLocalPath[Game_Dark_Forces], @@ -117,13 +214,69 @@ namespace TFE_DarkForces } #endif - // Program directory. + // 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::directoryExits(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::directoryExits(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; } @@ -136,12 +289,18 @@ namespace TFE_DarkForces return; } - // Fall back to same directory as videos. 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; @@ -149,11 +308,15 @@ namespace TFE_DarkForces 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."); } } @@ -163,17 +326,57 @@ namespace TFE_DarkForces 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 = archiveToBaseName(scene->archive); + 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)) + 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_videoPathResult; + return s_scriptPathResult; } return nullptr; } @@ -182,34 +385,48 @@ namespace TFE_DarkForces { if (!s_available || !scene || s_subtitleBasePath.empty()) { return nullptr; } - std::string baseName = archiveToBaseName(scene->archive); + std::string baseName = sceneBaseName(scene); if (baseName.empty()) { return nullptr; } - // Try language-specific subtitle first. const TFE_Settings_A11y* a11y = TFE_Settings::getA11ySettings(); - snprintf(s_subtitlePathResult, TFE_MAX_PATH, "%s%s.%s.srt", - s_subtitleBasePath.c_str(), baseName.c_str(), a11y->language.c_str()); - if (FileUtil::exists(s_subtitlePathResult)) + 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]) { - 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.%s.srt", + s_subtitleBasePath.c_str(), baseName.c_str(), lang); + if (FileUtil::exists(s_subtitlePathResult)) { return s_subtitlePathResult; } } - // Fall back to default (no language suffix). snprintf(s_subtitlePathResult, TFE_MAX_PATH, "%s%s.srt", s_subtitleBasePath.c_str(), baseName.c_str()); - if (FileUtil::exists(s_subtitlePathResult)) - { - return s_subtitlePathResult; - } + 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; } @@ -223,6 +440,8 @@ namespace TFE_DarkForces s_available = FileUtil::directoryExits(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 index 1b810bc59..1a3fbf8d1 100644 --- a/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.h +++ b/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.h @@ -1,19 +1,52 @@ #pragma once -// Detects remastered OGV cutscene files and maps CutsceneState archive -// names to video/subtitle paths (e.g. "ARCFLY.LFD" -> "arcfly.ogv"). +//============================================================================ +// 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(); - // Maps a scene's archive name to its OGV path, or nullptr if not found. + // 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); - // Returns the SRT subtitle path for a scene (language-specific, then default). + 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/TheForceEngine.vcxproj b/TheForceEngine/TheForceEngine.vcxproj index b362cd89b..7da966dfe 100644 --- a/TheForceEngine/TheForceEngine.vcxproj +++ b/TheForceEngine/TheForceEngine.vcxproj @@ -431,6 +431,7 @@ echo ^)"; + @@ -852,6 +853,7 @@ echo ^)"; + From bfebe5bf00d77d49da8ae0e4590b3affe1461daf Mon Sep 17 00:00:00 2001 From: elliotttate Date: Mon, 20 Apr 2026 22:32:18 -0400 Subject: [PATCH 4/4] Remaster cutscenes: fix directoryExits rename after master merge Master's b557d7ce renamed FileUtil::directoryExits -> directoryExists (fixing the typo). Update the remaster cutscene path resolution to match. No behavior change. --- .../TFE_DarkForces/Remaster/remasterCutscenes.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.cpp b/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.cpp index 18ea3c9d1..653c1e35a 100644 --- a/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.cpp +++ b/TheForceEngine/TFE_DarkForces/Remaster/remasterCutscenes.cpp @@ -121,7 +121,7 @@ namespace TFE_DarkForces for (int i = 0; i < s_subdirCount; i++) { snprintf(testPath, TFE_MAX_PATH, "%s%s", basePath, s_subdirNames[i]); - if (FileUtil::directoryExits(testPath)) + if (FileUtil::directoryExists(testPath)) { s_videoBasePath = testPath; TFE_System::logWrite(LOG_MSG, "Remaster", "Found remaster cutscenes at: %s", testPath); @@ -156,7 +156,7 @@ namespace TFE_DarkForces std::string custom = gameSettings->df_remasterCutscenesPath; // Trailing slash is required for our snprintf patterns below. if (custom.back() != '/' && custom.back() != '\\') { custom += '/'; } - if (FileUtil::directoryExits(custom.c_str())) + if (FileUtil::directoryExists(custom.c_str())) { s_videoBasePath = custom; TFE_System::logWrite(LOG_MSG, "Remaster", "Using custom cutscene path: %s", custom.c_str()); @@ -249,7 +249,7 @@ namespace TFE_DarkForces // Canonical location: sibling of movies/. char testPath[TFE_MAX_PATH]; snprintf(testPath, TFE_MAX_PATH, "%scutscene_scripts/", root.c_str()); - if (FileUtil::directoryExits(testPath)) + if (FileUtil::directoryExists(testPath)) { s_scriptBasePath = testPath; TFE_System::logWrite(LOG_MSG, "Remaster", "Found cutscene scripts at: %s", testPath); @@ -258,7 +258,7 @@ namespace TFE_DarkForces // Modder convenience: cutscene_scripts/ inside movies/. snprintf(testPath, TFE_MAX_PATH, "%scutscene_scripts/", s_videoBasePath.c_str()); - if (FileUtil::directoryExits(testPath)) + if (FileUtil::directoryExists(testPath)) { s_scriptBasePath = testPath; return; @@ -283,7 +283,7 @@ namespace TFE_DarkForces char testPath[TFE_MAX_PATH]; snprintf(testPath, TFE_MAX_PATH, "%sSubtitles/", s_videoBasePath.c_str()); - if (FileUtil::directoryExits(testPath)) + if (FileUtil::directoryExists(testPath)) { s_subtitleBasePath = testPath; return; @@ -437,7 +437,7 @@ namespace TFE_DarkForces s_videoBasePath += '/'; } - s_available = FileUtil::directoryExits(s_videoBasePath.c_str()); + s_available = FileUtil::directoryExists(s_videoBasePath.c_str()); if (s_available) { // Re-detect scripts and subtitles relative to the new base.