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..dfd48d9d6 100644 --- a/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp +++ b/TheForceEngine/TFE_DarkForces/Landru/cutscene.cpp @@ -11,6 +11,18 @@ #include #include #include +#ifdef ENABLE_OGV_CUTSCENES +#include +#include +#include +#include +#include +#include +#include "lmusic.h" +#include "cutscene_film.h" +#include "lsound.h" +#include +#endif using namespace TFE_Jedi; @@ -23,11 +35,278 @@ 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; + + // 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) { 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; + } + + // 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) + { + 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); + } + } + + // 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; + 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(); + ogvFilm_cleanup(); + return JFALSE; + } + + if (!TFE_OgvPlayer::update()) + { + TFE_OgvPlayer::close(); + 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()) + { + 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(); + } + } + + return JTRUE; } +#endif JBool cutscene_play(s32 sceneId) { @@ -47,10 +326,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 +351,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 +392,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 ^)"; + + +