diff --git a/docs/engine-re.md b/docs/engine-re.md index 20fa245..fbec96e 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -215,8 +215,8 @@ The meaningful consumer catalog is: | `COEXISTMESSKIP` | Value 1 lets Auto and all-message Skip remain simultaneously enabled. With zero, `adv_toggle_auto_mode@0x406b70` clears Skip and `adv_toggle_skip_mode@0x406c20` clears Auto. | Active ADV toggle behavior; currently missing from port | | `REDRAWTEXTONKEY` | Value 0 disables the wheel/key path that traverses and republishes the current text-history view through `CALLBACK_TEXT.BIN`. | Active ADV history-input policy; port uses its script callback path instead | | `WHEELKEYUP`, `WHEELKEYDOWN` | Rebinds the registry action bits from generic defaults 3/1 to 8/9. `adv_input_service_poll@0x411230` uses them for ADV wheel-key/history handling; raw `WM_MOUSEWHEEL` accumulation for op `0x10d` remains a separate channel. | Active ADV input binding; port does not source these values | -| `USEAPPDATAFOLDER`, `SAVEPATH` | Selects `%LOCALAPPDATA%\Eushully\姫狩りダンジョンマイスター\SAVE` as the native save root. | Active native path policy; intentionally redirected by port | -| `REGFILEPATH` | Supplies the Eushully/product-relative Windows registration-file path used by the native registration integration. | Windows integration only | +| `USEAPPDATAFOLDER`, `SAVEPATH` | Selects `%LOCALAPPDATA%\Eushully\姫狩りダンジョンマイスター\SAVE` as the native save root. | Active native path policy; the port redirects it through the shared profile-root policy | +| `REGFILEPATH` | Supplies the Eushully/product-relative directory for native `SYS4REG.INI`. With Himegari's `USEAPPDATAFOLDER=1`, AGE resolves `%LOCALAPPDATA%\Eushully\姫狩りダンジョンマイスター\SYS4REG.INI`. | Active native engine-options path; redirected with saves while retaining the native format | | `SAVEVERSION` | Selects numbered-save layout 3.10 and enables the matching append-catalog persistence structures. | Active persistence ABI | | `GAMEVERSION`, `VERREGPOS` | Seeds display/registration version `1.00`, then permits the GUID-selected Windows registry `CLASS00` value to replace it. | Windows version/registration metadata | | `REGKEY` | A nonzero value activates `registration_validate_key_file@0x46fc80`, which opens `SYS4RK.BIN`, validates its header/transformed payload and both CRC variants, and publishes the registration result. | Native registration/key validation | @@ -234,9 +234,10 @@ paths; those keys have not yet been migrated to the parsed registry. The remaining generic-profile gap is semantic application of further portable settings. In particular, click-cancel, Auto/Skip coexistence, cursor policy, redraw policy, and wheel action ids must be decided from -this evidence instead of AGE's compiled defaults. The port deliberately redirects the native AppData path to -Godot `user://SAVE`, and it should not reproduce legacy DirectDraw selection or native registration/key -validation. +this evidence instead of AGE's compiled defaults. The port deliberately redirects the native profile to +Godot `user://`: save payloads remain under `user://SAVE`, while engine options retain AGE's native +`SYS4REG.INI` filename and format at `user://SYS4REG.INI`. +It should not reproduce legacy DirectDraw selection or native registration/key validation. ### ops `0x1a2`/`0x1a3` store and restore shared `SAVE.DAT` integer cells (resolved 2026-07-20) @@ -1851,10 +1852,11 @@ Save paths are native engine policy, not script-provided strings. Every numbered `SAVE%2.2d.STH`, `SAVE.DAT`, `SAVE.BAK`, `RT.DAT`, and their `$$` temporary names) under that root. The script operands select the operation and numbered slot only. -The port preserves that ownership boundary while intercepting the root: Godot supplies `user://SAVE`, and -`DirectoryNativeDatStore` owns the fixed native names beneath it. This isolates authored port saves from the -original installation while retaining compatible file structure; another root can be injected without -changing script semantics. +The port preserves that ownership boundary while intercepting the enclosing profile root. For Himegari, +`Sys4PersistencePaths` validates that `SAVEPATH` is beneath `REGFILEPATH`, maps their relative `SAVE` tail +under Godot `user://`, and supplies `user://SAVE` to `DirectoryNativeDatStore`. This isolates authored port +saves from the original installation while retaining compatible file structure; another profile root can +be injected without changing script semantics. The resolver reads two per-game settings from the SYS4INI-backed settings registry: `set:UseAppDataFolder` and `set:SavePath`. When `UseAppDataFolder == 1`, the modern-Windows branch @@ -1939,7 +1941,7 @@ global pointers. `RT.DAT` is now implemented as the separate S3RT layer describe validates its distinct compatibility id, queries metadata without decoding payloads, and performs exact paired `.DAT`/`.STH` copy/delete status layering. `NumberedThumbnailCodec` reads and writes the native BMP dialect through host surface capture/replacement, and ops `0x1a0`, `0x1ab`–`0x1af` (including `0x1ad`'s -frame marker) are wired. Godot injects the store at `user://SAVE`. +frame marker) are wired. Godot's shared profile-path resolver injects the store at `user://SAVE`. `NativeNumberedSaveCodec`, `NativeTextHistoryCodec`, and `NativeGfxPersistenceCodec` own the complete layout-3 numbered body, global/frame/gfx state, and appended history tail. The VM wires `0x19e`, data-only `0x19f`, full load @@ -2794,8 +2796,8 @@ sound. The port now forwards the native start mode through its host seam and set Godot's WAV loop mode before playback; `0xb5` explicitly supplies mode 0 and `0xba` supplies mode 1. The focused VM regression covers both modes, and the renamed/commented `/v2` handler is saved. -**CONFIG mixer ABI reconnaissance (2026-07-28).** Four remaining opcodes form one internally dependent -settings surface and should be implemented together: +**CONFIG mixer ABI implemented (mapped 2026-07-28; landed 2026-07-29).** Four opcodes form one +internally dependent settings surface: - `0xc5 get-audio-volume(category, out)` reads `sound:Volume0..Volume4`; selectors `0..4` mean master/music/SFX/voice/movie and values are basis points (`0..10000`). @@ -2809,10 +2811,39 @@ settings surface and should be implemented together: CONFIG contains all 37 sites: nine volume reads, twelve volume writes, fifteen route writes, and one route-query loop. Skipping `0xc5` leaves its output operand stale, so the visual slider calculation can be -wrong even before the user changes anything; skipping the setters makes its controls inert. The `/v2` -handlers, route helpers, and live volume-apply workers are named/commented and saved. Port implementation -should provide one shared mixer/settings state to the VM and Godot audio buses rather than treating the four -opcodes as isolated host calls. +wrong even before the user changes anything; skipping the setters makes its controls inert. + +The native default registry initializes `sound:Volume0..Volume4` to `-1`. During audio startup, each +nonnegative entry is applied and `-1` deliberately leaves the hardware-default gain unchanged. Himegari's +CONFIG reset action later writes `10000/6500/5000/8000` for master/music/SFX/voice. Route defaults are +enabled after `NOSETMUSIC=3` transforms music to enabled state 2 and the generic SE/Voice/Movie flags begin +at one. The saved `/v2` image now records these defaults plus the exact route side effects: +`sound_set_music_route_enabled@0x407f80` rebuilds the current music route, disabling SFX calls its +active-channel stop helper, disabling voice stops channel 12, and movie enablement changes routing without +stopping the movie. + +The port now owns one `AudioMixerSettings` instance across fresh scene VMs. `0xc5`/`0xc7` query it and +`0xc6`/`0x1ba` update it before calling the host's live-application seam. Godot maps selectors 0..4 to its +Master/Music/SFX/Voice/Movie buses; the parent Master bus and category bus multiply naturally, and route +changes mute immediately while preserving the native stop/restart distinctions above. + +Persistence uses AGE's CP932 `SYS4REG.INI`, not a port-owned format. +`engine_resolve_sys4reg_ini_path@0x46b150` selects the fixed filename under the SYS4INI +`REGFILEPATH`; Himegari therefore uses +`%LOCALAPPDATA%\Eushully\姫狩りダンジョンマイスター\SYS4REG.INI`. +`engine_settings_load_sys4reg_ini@0x46b6e0` registers and imports the `[display]`, `[sound]`, +`[message]`, and `[system]` projections, including all nine audio keys. `Sys4RegIniStore` reads and +updates only `Music`, `SE`, `Voice`, `Movie`, and `Volume0..4`, preserving all unrelated lines, sections, +key spelling/order, CP932 text, and newline style. Missing keys use native defaults. Music retains AGE's +signed value band: Himegari's enabled state `2` disables to `-1` and re-enables to `2`, rather than being +silently normalized to `1`. Writes use a sibling temporary file followed by replacement. + +AGE resolves save and settings locations independently, but Himegari makes `SAVEPATH` the `SAVE` +descendant of `REGFILEPATH`. `Sys4PersistencePaths` models both native resolutions together. Godot's +current profile override replaces the resolved `REGFILEPATH` directory with `user://`, preserving the +relative `SAVE` tail and yielding `user://SYS4REG.INI` plus `user://SAVE`. If a future profile supplies +unrelated paths, a single-root override is rejected instead of guessing; native resolution remains +available as the exact `USEAPPDATAFOLDER` plus independent-path policy. ### Scene-entry state snapshot — auto-seeding single-scene runs (2026-07-09) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 6de5953..35801b8 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -230,21 +230,29 @@ The requested CP932 face is copied into the primary LOGFONT lfFaceName and AGE a - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0xc5_get_audio_volume@0x428470 selects sound:Volume0..Volume4 for operand 1 values 0..4, queries the engine settings registry, and writes the result to operand 2. CONFIG.BIN uses the results to draw and adjust four visible category sliders; its initialization also sets Volume0..Volume3 to 10000/6500/5000/8000. +Port status (2026-07-29): implemented against the profile-lifetime AudioMixerSettings registry. The native -1 unconfigured sentinel is preserved; CONFIG reads receive the exact stored basis-point value. + ### 0xc6 `set-audio-volume` (set-audio-volume, argc 2) - **summary:** (category)(basis_points) — update the configured 0..10000 audio volume and apply it immediately: 0=master, 1=music, 2=SFX, 3=voice, 4=movie. - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0xc6_set_audio_volume@0x4206d0 writes sound:Volume0..Volume4 through the settings registry and dispatches selectors 0..4 to the live master/music/SFX/voice/movie volume workers. The master worker reapplies dependent volume to active movies and sound buffers; category workers update active BGM, ten SFX channels, voice channel 12, or the movie basis respectively. +Port status (2026-07-29): implemented with native SYS4REG.INI [sound] persistence and live Godot Master/Music/SFX/Voice/Movie bus gain changes. The CP932 preserving writer retains unrelated options; master and category buses compose multiplicatively like native basis-point application. + ### 0xc7 `get-audio-route-enabled` (get-audio-route-enabled, argc 2) - **summary:** (category)(out_enabled) — query whether an audio route is enabled: 1=music, 2=SFX, 3=voice, 4=movie. Writes boolean 0 or 1. - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0xc7_get_audio_route_enabled@0x4285a0 queries sound:Music/SE/Voice/Movie for selectors 1..4 and writes boolean 0/1 to operand 2. CONFIG.BIN's only site loops selectors 1..3 to choose enabled/disabled presentation. +Port status (2026-07-29): implemented as the read half of the shared audio-route registry loaded from native SYS4REG.INI. + ### 0x1ba `set-audio-route-enabled` (set-audio-route-enabled, argc 2) - **summary:** (category)(enabled) — enable or disable an audio route and persist the setting: 1=music, 2=SFX, 3=voice, 4=movie. - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x1ba_set_audio_route_enabled@0x420860 dispatches selectors 1..4 to the music/SFX/voice/movie enable workers. Those workers update the live route, stop active playback when disabling where applicable, write sound:Music/SE/Voice/Movie through the settings registry, and reject selectors outside 1..4. All 15 CONFIG.BIN sites pass literal 0/1. +Port status (2026-07-29): implemented with native SYS4REG.INI route persistence and live bus muting. AGE's raw music value band is preserved (Himegari 2 enabled toggles to -1 disabled); music changes restart/stop the retained stream, disabling SFX or voice stops active playback, and movie changes remain non-destructive. + ### 0x1bd `play-history-voice` (u0041D910, argc 1) - **summary:** Replay a voice id selected from retained ADV text history using native playback/history variant 1, preserving normal Skip and Auto-voice state behavior. - **grounding:** source=investigation, confidence=high diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index e963cda..14eb8a5 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -814,11 +814,28 @@ effectful fallthrough inventory from 23 to 22 distinct opcodes. Focused tests co odd/even period timing, continuous-presentation classification, and matrix order; the full engine suite, warning-free Godot build, and Himegari-targeted threaded selftest pass (468 engine tests). -**NEXT:** implement CONFIG's mixer ABI as one four-opcode tranche rather than piecemeal: -`0xc5` gets master/music/SFX/voice/movie volume, `0xc6` sets and applies it, `0x1ba` enables/disables the -four non-master routes, and `0xc7` queries route state. Those 37 CONFIG sites require shared persistent -settings plus live Godot-bus application; implementing only a setter or getter would leave the menu -internally inconsistent. Low-frequency developer-only calls remain behind these two tranches. +**CONFIG mixer tranche implemented (2026-07-29).** `0xc5`/`0xc6` now get/set master, music, SFX, +voice, and movie volume in native basis points; `0x1ba`/`0xc7` set/get the four non-master route flags. +The registry is shared across fresh scene VMs and persisted in AGE's native CP932 `SYS4REG.INI`. A shared +path resolver models SYS4INI's independent `SAVEPATH`/`REGFILEPATH` choices; Godot redirects Himegari's +related profile root to `user://`, yielding `user://SAVE` and `user://SYS4REG.INI`. The compatibility +writer updates only the nine audio keys and preserves every unrelated option. Raw music state retains +native `2 ↔ -1` band toggling. Godot applies +gains through its nested audio buses, so master and category volume compose, and route changes immediately +stop/mute active playback according to the native worker distinctions. All 37 CONFIG sites now dispatch, +reducing the effectful fallthrough inventory from 22 to 18 distinct opcodes. Seven focused tests cover +native defaults, category isolation, getter/setter round trips, idempotent routes, cross-VM state, invalid +selectors, path resolution, CP932 INI preservation, and persistence. All 475 engine tests, the +warning-free Godot build, opcode lint, and the Himegari-targeted threaded selftest pass. + +**Post-tranche rerank:** a fresh 481-script aggregate confirms exactly 18 effectful gaps totaling only +39 instructions. The widest are unknown `0x22` (eight sites across seven scripts), unknown `0x230` +(six sites across two scripts), then `0x21` and `0x1b2` (three sites each); every other gap has at most two +sites. CONFIG itself is now 1659/1665 instructions implemented, with four unrelated calls remaining +(`0x142` twice, `0xb7`, and `0xb8`). + +**NEXT:** investigate `0x22` as the widest remaining independent opcode, with `0x230` as the next +candidate if `0x22` proves to own a larger subsystem. ## Later Phase B breadth diff --git a/docs/platform-portability.md b/docs/platform-portability.md index c90dde3..780b52e 100644 --- a/docs/platform-portability.md +++ b/docs/platform-portability.md @@ -16,7 +16,8 @@ The VM and content pipeline are already mostly platform-neutral: - Movie payloads arrive from `IAssetStore` as owned bytes and decoded frames enter the compositor as the platform-neutral `RgbaImage` type. - Native-compatible persistence uses managed streams behind `INativeDatStore`; Godot redirects AGE's - engine-owned root to `user://SAVE` while retaining its fixed portable DAT/STH filenames. + related save/settings profile to `user://`, yielding `user://SAVE` and `user://SYS4REG.INI` while + retaining native filenames and formats. The sole movie path now uses the project-owned FFmpeg C ABI rather than a Windows multimedia API, but only a Windows-x64 native bundle is built and staged today. The accepted DirectShow fallback was deleted after the @@ -34,7 +35,7 @@ or replaced before claiming portable exports. | Movie audio | ABI v2 returns timestamped stereo float PCM; bounded managed buffering feeds a per-playback Godot `AudioStreamGenerator` and routes native movie flags to engine buses | All 29 installed audio-bearing streams decode with signal; synchronized LOGO/OP/CHAPTER playback is audibly accepted | Treat absent, distorted, or unsynchronized audio from an audio-bearing movie as a runtime bug | | ADV font discovery/raster fidelity | Opcode `0x1a5` reaches presentation; `godot/Main.cs` loads Windows `MS 明朝`/`MS ゴシック` from their known TTC files when available, otherwise uses the existing Japanese-font/default fallback. AGE actually uses a display information DC, weight-700 `LOGFONTA`, `GGO_GRAY4_BITMAP`, and its own integer glyph/outline compositor; the current `Label` backend substitutes FreeType embolden and a Godot outline | On the reference Windows install, regular advances and direct SC0000 placement/bounds agree, but the behaviorally different provisional bold path has 45% fewer bright pixels and much softer edge coverage. Other platforms normally lack the proprietary faces, so substitute metrics remain profile-dependent | Deferred until gameplay settles: implement the Phase-E decoded mask/metrics/compositor backlog in `docs/remake-architecture-and-roadmap.md`, with a shipped GDI reference backend on Windows and an explicitly defined portable rasterizer/substitution policy | | Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules | -| Save/profile storage | Managed `DirectoryNativeDatStore` under Godot `user://SAVE`; native S3SD/S4SD/S3RT files and 24-bit BMP thumbnails | No Win32 path API at runtime; port saves remain isolated from the original installation | Validate replace/flush, case, permissions, and interrupted-write behavior on each export target | +| Save/profile/settings storage | `Sys4PersistencePaths` models AGE's independent `SAVEPATH` and `REGFILEPATH` resolutions. Godot replaces Himegari's related profile directory with `user://`, yielding `user://SAVE` for native S3SD/S4SD/S3RT files and thumbnails plus `user://SYS4REG.INI` for the BOM-less CP932 options file; the preserving writer changes only its nine `[sound]` keys | Save payloads and engine options are isolated together without changing either native format. Native/drop-in resolution remains available through `USEAPPDATAFOLDER` plus both SYS4INI paths. A single-root override is rejected when `SAVEPATH` is not beneath `REGFILEPATH`, preventing cross-profile guesses | Expose explicit profile/native selection through the future launcher and allow independent overrides for profiles whose two native paths are unrelated. Validate CP932 availability, replace/flush, case, permissions, and interrupted-write behavior on each export target | | Game-install and repository discovery | `GameRootSelection` accepts `--game-root`, then probes the executable directory and current working directory for `SYS4INI.BIN`; on Unix the frontend prefers inherited shell `PWD` because Godot may change the process directory during project startup. Godot injects the selected root into its catalog and loose-first ALF store. `Paths.cs` remains the development/test locator for generated opcode data, diagnostics, and CLI conveniences | Installed game data no longer depends on the workspace sibling layout, enabling an executable beside `AGE.EXE`, a terminal launch from the install, or a launcher-supplied absolute profile root. A packaged export still needs its generated runtime metadata bundled independently of repository discovery | Add export packaging for the opcode/profile artifacts, let the future profile launcher pass `--game-root`, and run executable-directory/CWD plus case/permission smoke gates on Linux and macOS | | Archive parity oracle | One integration test launches `bin/BinExtractALF.exe` | Windows-only test helper, not a shipped runtime dependency | Skip/replace on non-Windows CI; runtime ALF/AAI readers do not depend on it | | Native RE tools | Frida/Ghidra helpers target the original `AGE.EXE`; supporting utilities include Windows executables and Windows command conventions | Development/research only | Keep separate from export requirements; document platform prerequisites per tool | diff --git a/docs/sys4-format-notes.md b/docs/sys4-format-notes.md index cd46d60..573567f 100644 --- a/docs/sys4-format-notes.md +++ b/docs/sys4-format-notes.md @@ -97,6 +97,42 @@ permissive than AGE's fixed registry so unimplemented keys in another profile re AGE's registered `640x480` defaults when absent or nonpositive/nonnumeric, while unsafe RGBA allocations are rejected before presentation construction. Append `S4AC` catalogs do not own startup settings. +### Native `SYS4REG.INI` engine options — CONFIRMED + +`REGFILEPATH` selects the directory for AGE's fixed `SYS4REG.INI` filename. Himegari combines +`USEAPPDATAFOLDER=1` with `REGFILEPATH=Eushully\姫狩りダンジョンマイスター`, producing: + +```text +%LOCALAPPDATA%\Eushully\姫狩りダンジョンマイスター\SYS4REG.INI +``` + +The file is an ordinary BOM-less CP932 INI with CRLF line endings in the installed profile. Its native +sections are `[display]`, `[sound]`, `[message]`, and `[system]`. The CONFIG mixer opcodes own these +`[sound]` integer keys: + +```ini +Music=2 +SE=1 +Voice=1 +Movie=1 +Volume0=5500 +Volume1=2500 +Volume2=3500 +Volume3=3500 +Volume4=-1 +``` + +The shown values are the installed profile's current values, not compiled defaults. `Volume0..4` mean +master/music/SFX/voice/movie. Music uses signed bands rather than a normalized boolean: nonnegative is +enabled, negative is disabled, and the native setter moves between bands by adding or subtracting three. +SE/Voice/Movie use zero/nonzero. The file is engine-option persistence, separate from `SAVE.DAT`, +`RT.DAT`, and numbered saves. + +`SAVEPATH` and `REGFILEPATH` are independent native settings rather than one serialized profile-root +field. Himegari deliberately relates them: `SAVEPATH` is the `SAVE` descendant of `REGFILEPATH`. +The port's shared profile override preserves that relative layout, producing `user://SAVE` and +`user://SYS4REG.INI`; the file contents and names remain native-compatible. + ## Native persistence files — Himegari 3.10 This section is the canonical binary-format specification for the persistence files used by Himegari. @@ -105,7 +141,7 @@ Native function provenance, opcode behavior, filesystem policy, and restore cont `global-reference.md`. Unless noted otherwise, offsets are byte offsets, integers are little-endian DWORDs, and strings are -NUL-terminated CP932. Himegari separates four physical domains: +NUL-terminated CP932. Himegari's save/profile subsystem separates four physical domains: | File | Scope | Structural owner | Semantic owner | |---|---|---|---| diff --git a/engine/Age.Engine.Tests/AudioMixerOpcodeTests.cs b/engine/Age.Engine.Tests/AudioMixerOpcodeTests.cs new file mode 100644 index 0000000..7de093c --- /dev/null +++ b/engine/Age.Engine.Tests/AudioMixerOpcodeTests.cs @@ -0,0 +1,219 @@ +using Age.Engine.Model; +using Age.Engine.Persistence; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using System.Text; + +public class AudioMixerOpcodeTests +{ + private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson); + private static Operand G(int address) => new(3, address); + private static Operand I(long value) => new(0, value); + private static (int, Operand[]) Exit() => (0x2, Array.Empty()); + + [Fact] + public void NativeDefaultsUseUnconfiguredVolumesAndEnabledRoutes() + { + var settings = new AudioMixerSettings(); + + for (int category = 0; category < AudioMixerSettings.CategoryCount; category++) + { + Assert.True(settings.TryGetVolume(category, out int volume)); + Assert.Equal(AudioMixerSettings.UnconfiguredVolume, volume); + } + for (int category = (int)AudioMixerCategory.Music; + category < AudioMixerSettings.CategoryCount; + category++) + { + Assert.True(settings.TryGetRouteEnabled(category, out bool enabled)); + Assert.True(enabled); + } + } + + [Fact] + public void MixerOpcodesRoundTripCategoriesAndApplyLiveChanges() + { + var settings = new AudioMixerSettings(); + var host = new RecordingHost(); + Script scene = ScriptAssembler.Assemble(Table, "CONFIG_MIXER", + [ + (0xc6, [I(0), I(8000)]), + (0xc6, [I(3), I(4500)]), + (0x1ba, [I(1), I(0)]), + (0xc5, [I(0), G(10)]), + (0xc5, [I(3), G(11)]), + (0xc5, [I(2), G(12)]), + (0xc7, [I(1), G(13)]), + (0xc7, [I(2), G(14)]), + Exit(), + ], []); + var vm = new VirtualMachine(scene, Table, host, audioMixerSettings: settings); + + vm.Run(); + + Assert.Equal(8000, vm.Globals[10]); + Assert.Equal(4500, vm.Globals[11]); + Assert.Equal(AudioMixerSettings.UnconfiguredVolume, vm.Globals[12]); + Assert.Equal(0, vm.Globals[13]); + Assert.Equal(1, vm.Globals[14]); + Assert.Equal([(0, 8000), (3, 4500)], host.AudioVolumeChanges); + Assert.Equal([(1, false)], host.AudioRouteChanges); + } + + [Fact] + public void RouteSetterIsIdempotentLikeNativeWorkers() + { + var host = new RecordingHost(); + Script scene = ScriptAssembler.Assemble(Table, "CONFIG_ROUTE", + [ + (0x1ba, [I(2), I(0)]), + (0x1ba, [I(2), I(0)]), + (0x1ba, [I(2), I(1)]), + (0x1ba, [I(2), I(1)]), + Exit(), + ], []); + + new VirtualMachine(scene, Table, host).Run(); + + Assert.Equal([(2, false), (2, true)], host.AudioRouteChanges); + } + + [Fact] + public void GameSessionCarriesMixerStateAcrossFreshSceneVms() + { + var session = new GameSession(); + Script setter = ScriptAssembler.Assemble(Table, "CONFIG_SET", + [ + (0xc6, [I(2), I(6250)]), + (0x1ba, [I(3), I(0)]), + Exit(), + ], []); + Script getter = ScriptAssembler.Assemble(Table, "CONFIG_GET", + [ + (0xc5, [I(2), G(20)]), + (0xc7, [I(3), G(21)]), + Exit(), + ], []); + + session.RunScene(setter, Table, new RecordingHost()); + session.RunScene(getter, Table, new RecordingHost()); + + Assert.Equal(6250, session.Globals[20]); + Assert.Equal(0, session.Globals[21]); + } + + [Fact] + public void NativeSys4RegIniRoundTripsAudioAndPreservesOtherOptions() + { + string directory = Path.Combine(Path.GetTempPath(), $"age-audio-settings-{Guid.NewGuid():N}"); + string path = Path.Combine(directory, Sys4RegIniStore.FileName); + try + { + Directory.CreateDirectory(directory); + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + Encoding cp932 = Encoding.GetEncoding(932); + const string original = + "[display]\r\nScreenMode=1\r\n\r\n" + + "[sound]\r\nSound=1\r\nMusic=2\r\nSE=7\r\nVoice=1\r\nMovie=1\r\n" + + "Volume0=5500\r\nVolume1=2500\r\nVolume2=3500\r\nVolume3=3500\r\nVolume4=-1\r\n" + + "UseDirectSound=1\r\n\r\n" + + "[message]\r\nFont=MS 明朝\r\nMessageSpeed=50\r\n"; + File.WriteAllText(path, original, cp932); + + var store = new Sys4RegIniStore(path, defaultMusicRouteValue: 2); + AudioMixerSettings settings = store.Load(); + settings.Changed += store.Save; + Assert.True(settings.TrySetVolume((int)AudioMixerCategory.Master, 9000)); + Assert.True(settings.TrySetVolume((int)AudioMixerCategory.Movie, 3750)); + Assert.True(settings.TrySetRouteEnabled( + (int)AudioMixerCategory.Voice, false, out bool changed)); + Assert.True(changed); + Assert.True(settings.TrySetRouteEnabled( + (int)AudioMixerCategory.Music, false, out changed)); + Assert.True(changed); + Assert.Contains("Music=-1\r\n", File.ReadAllText(path, cp932)); + Assert.True(settings.TrySetRouteEnabled( + (int)AudioMixerCategory.Music, true, out changed)); + Assert.True(changed); + + AudioMixerSettings loaded = store.Load(); + string rewritten = File.ReadAllText(path, cp932); + + Assert.True(loaded.TryGetVolume((int)AudioMixerCategory.Master, out int master)); + Assert.True(loaded.TryGetVolume((int)AudioMixerCategory.Movie, out int movie)); + Assert.True(loaded.TryGetRouteEnabled((int)AudioMixerCategory.Voice, out bool voice)); + Assert.Equal(9000, master); + Assert.Equal(3750, movie); + Assert.False(voice); + Assert.Contains("[display]\r\nScreenMode=1\r\n", rewritten); + Assert.Contains("Sound=1\r\n", rewritten); + Assert.Contains("Music=2\r\n", rewritten); + Assert.Contains("SE=7\r\n", rewritten); + Assert.Contains("UseDirectSound=1\r\n", rewritten); + Assert.Contains("[message]\r\nFont=MS 明朝\r\nMessageSpeed=50\r\n", rewritten); + Assert.DoesNotContain("engine-settings", rewritten); + } + finally + { + if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void NativePersistencePathsComeFromIndependentSys4IniProfileValues() + { + Sys4AssetCatalog catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); + string localAppData = Path.Combine(Path.GetTempPath(), "native-appdata-root"); + + Sys4PersistencePaths paths = Sys4PersistencePaths.ResolveNative( + catalog.StartupSettings, "C:\\unused-game-root", localAppData); + + Assert.Equal( + Path.Combine(localAppData, "Eushully", "姫狩りダンジョンマイスター", "SAVE"), + paths.SaveDirectory); + Assert.Equal( + Path.Combine(localAppData, "Eushully", "姫狩りダンジョンマイスター", "SYS4REG.INI"), + paths.Sys4RegIniPath); + } + + [Fact] + public void ProfileOverrideRedirectsSaveAndSettingsAsOneNativeLayout() + { + Sys4AssetCatalog catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); + string profileRoot = Path.Combine(Path.GetTempPath(), "redirected-profile-root"); + + Sys4PersistencePaths paths = Sys4PersistencePaths.ResolveProfileOverride( + catalog.StartupSettings, profileRoot); + Sys4RegIniStore store = Sys4RegIniStore.ForPath( + catalog.StartupSettings, paths.Sys4RegIniPath); + + Assert.Equal(Path.Combine(profileRoot, "SAVE"), paths.SaveDirectory); + Assert.Equal(Path.Combine(profileRoot, "SYS4REG.INI"), paths.Sys4RegIniPath); + Assert.Equal(paths.Sys4RegIniPath, store.FilePath); + } + + [Fact] + public void InvalidMixerCategoriesWarnAndLeaveOutputsUntouched() + { + var host = new RecordingHost(); + Script scene = ScriptAssembler.Assemble(Table, "CONFIG_INVALID", + [ + (0xc5, [I(5), G(30)]), + (0xc6, [I(-1), I(5000)]), + (0xc7, [I(0), G(31)]), + (0x1ba, [I(5), I(1)]), + Exit(), + ], []); + var vm = new VirtualMachine(scene, Table, host); + vm.Globals[30] = 123; + vm.Globals[31] = 456; + + vm.Run(); + + Assert.Equal(123, vm.Globals[30]); + Assert.Equal(456, vm.Globals[31]); + Assert.Equal(4, host.Warnings.Count); + Assert.Empty(host.AudioVolumeChanges); + Assert.Empty(host.AudioRouteChanges); + } +} diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index af512a9..96810ef 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -44,6 +44,8 @@ internal class RecordingHost : IHost public readonly List SfxReleases = new(); public readonly List BgmTracks = new(); public readonly List<(int Target, long Duration)> BgmFades = new(); + public readonly List<(int Category, int BasisPoints)> AudioVolumeChanges = new(); + public readonly List<(int Category, bool Enabled)> AudioRouteChanges = new(); public readonly List<(long Resource, int Surface, long Flags, long SyncMask)> Movies = new(); public System.Action? OnPlayMovie; public long? MovieStopTimeMs; @@ -182,6 +184,10 @@ internal class RecordingHost : IHost => ScheduledSfxStarts.Add((channel, startMode, delayMs)); public void ReleaseSoundEffect(int channel) => SfxReleases.Add(channel); public void FadeBgm(int targetPercent, long durationMs) => BgmFades.Add((targetPercent, durationMs)); + public void ApplyAudioVolume(int category, int basisPoints) + => AudioVolumeChanges.Add((category, basisPoints)); + public void ApplyAudioRouteEnabled(int category, bool enabled) + => AudioRouteChanges.Add((category, enabled)); public long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) { Movies.Add((resourceId, surfaceSlot, movieFlags, syncMask)); diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 40dc3df..332bded 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -141,6 +141,9 @@ public interface IHost void ScheduleSoundEffectStart(int channel, int startMode, long delayMs) { } void ReleaseSoundEffect(int channel) { } void FadeBgm(int targetPercent, long durationMs) { } + // AGE's sound:* settings registry is VM-owned; the host applies changes to active playback. + void ApplyAudioVolume(int category, int basisPoints) { } + void ApplyAudioRouteEnabled(int category, bool enabled) { } // Native op 0x236 binds a movie decoder to an existing retained texture surface. // Playback is non-modal: the VM advances to the following instruction while the host publishes frames. /// The initialized movie graph's stop position in truncated integer milliseconds, or null diff --git a/engine/Age.Engine/Model/AudioMixerSettings.cs b/engine/Age.Engine/Model/AudioMixerSettings.cs new file mode 100644 index 0000000..6946099 --- /dev/null +++ b/engine/Age.Engine/Model/AudioMixerSettings.cs @@ -0,0 +1,131 @@ +namespace Age.Engine.Model; + +public enum AudioMixerCategory +{ + Master = 0, + Music = 1, + SoundEffect = 2, + Voice = 3, + Movie = 4, +} + +public sealed record AudioMixerSettingsSnapshot(int[] Volumes, bool[] Routes); + +/// +/// Profile-lifetime projection of AGE's sound:* settings registry. Volume -1 is the native +/// unconfigured sentinel; nonnegative values are basis points. Route zero is unused because master +/// has no independent enable switch. +/// +public sealed class AudioMixerSettings +{ + public const int CategoryCount = 5; + public const int UnconfiguredVolume = -1; + public const int MaximumVolume = 10_000; + + private readonly object _lock = new(); + private readonly int[] _volumes = + [ + UnconfiguredVolume, UnconfiguredVolume, UnconfiguredVolume, + UnconfiguredVolume, UnconfiguredVolume, + ]; + private readonly bool[] _routes = [false, true, true, true, true]; + + public event Action? Changed; + + public bool TryGetVolume(int category, out int basisPoints) + { + lock (_lock) + { + if ((uint)category >= CategoryCount) + { + basisPoints = default; + return false; + } + basisPoints = _volumes[category]; + return true; + } + } + + public bool TrySetVolume(int category, long basisPoints) + { + AudioMixerSettingsSnapshot snapshot; + lock (_lock) + { + if ((uint)category >= CategoryCount) return false; + _volumes[category] = checked((int)basisPoints); + snapshot = SnapshotLocked(); + } + Changed?.Invoke(snapshot); + return true; + } + + public bool TryGetRouteEnabled(int category, out bool enabled) + { + lock (_lock) + { + if (category is < (int)AudioMixerCategory.Music or >= CategoryCount) + { + enabled = default; + return false; + } + enabled = _routes[category]; + return true; + } + } + + public bool TrySetRouteEnabled(int category, bool enabled, out bool changed) + { + AudioMixerSettingsSnapshot? snapshot = null; + lock (_lock) + { + if (category is < (int)AudioMixerCategory.Music or >= CategoryCount) + { + changed = false; + return false; + } + changed = _routes[category] != enabled; + if (changed) + { + _routes[category] = enabled; + snapshot = SnapshotLocked(); + } + } + if (snapshot != null) Changed?.Invoke(snapshot); + return true; + } + + public AudioMixerSettingsSnapshot Snapshot() + { + lock (_lock) return SnapshotLocked(); + } + + public void Replace(AudioMixerSettingsSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + ValidateSnapshot(snapshot); + lock (_lock) + { + snapshot.Volumes.CopyTo(_volumes, 0); + snapshot.Routes.CopyTo(_routes, 0); + _routes[(int)AudioMixerCategory.Master] = false; + } + } + + public static void ValidateSnapshot(AudioMixerSettingsSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + if (snapshot.Volumes.Length != CategoryCount) + throw new InvalidDataException($"Audio mixer volume table must contain {CategoryCount} values."); + if (snapshot.Routes.Length != CategoryCount) + throw new InvalidDataException($"Audio mixer route table must contain {CategoryCount} values."); + if (snapshot.Routes[(int)AudioMixerCategory.Master]) + throw new InvalidDataException("Audio mixer master route flag must be false."); + foreach (int value in snapshot.Volumes) + if (value is < UnconfiguredVolume or > MaximumVolume) + throw new InvalidDataException( + $"Audio mixer volume {value} is outside {UnconfiguredVolume}..{MaximumVolume}."); + } + + private AudioMixerSettingsSnapshot SnapshotLocked() + => new(_volumes.ToArray(), _routes.ToArray()); +} diff --git a/engine/Age.Engine/Persistence/Sys4PersistencePaths.cs b/engine/Age.Engine/Persistence/Sys4PersistencePaths.cs new file mode 100644 index 0000000..9fb50f2 --- /dev/null +++ b/engine/Age.Engine/Persistence/Sys4PersistencePaths.cs @@ -0,0 +1,98 @@ +using System.Globalization; +using Age.Engine.Sys4; + +namespace Age.Engine.Persistence; + +/// +/// Resolves the two native AGE persistence locations from one SYS4INI profile. Native mode follows +/// USEAPPDATAFOLDER plus the independent SAVEPATH and REGFILEPATH values. A profile override replaces +/// the resolved REGFILEPATH directory while preserving SAVEPATH's relative tail beneath it. +/// +public readonly record struct Sys4PersistencePaths( + string SaveDirectory, + string Sys4RegIniPath) +{ + public static Sys4PersistencePaths ResolveNative( + Sys4StartupSettings startupSettings, + string gameRoot, + string? localApplicationDataRoot = null) + { + ArgumentNullException.ThrowIfNull(startupSettings); + ArgumentException.ThrowIfNullOrWhiteSpace(gameRoot); + + bool useAppData = int.TryParse( + startupSettings.GetValueOrDefault("USEAPPDATAFOLDER"), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int useAppDataValue) + && useAppDataValue != 0; + string root = useAppData + ? localApplicationDataRoot + ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + : gameRoot; + if (string.IsNullOrWhiteSpace(root)) + throw new InvalidDataException("Native AGE persistence root is unavailable."); + + string saveDirectory = CombineWindowsRelativePath( + root, startupSettings.GetValueOrDefault("SAVEPATH"), "SAVEPATH"); + string settingsDirectory = CombineWindowsRelativePath( + root, startupSettings.GetValueOrDefault("REGFILEPATH"), "REGFILEPATH"); + return new Sys4PersistencePaths( + saveDirectory, + Path.Combine(settingsDirectory, Sys4RegIniStore.FileName)); + } + + public static Sys4PersistencePaths ResolveProfileOverride( + Sys4StartupSettings startupSettings, + string profileRoot) + { + ArgumentNullException.ThrowIfNull(startupSettings); + ArgumentException.ThrowIfNullOrWhiteSpace(profileRoot); + + IReadOnlyList settingsComponents = ParseWindowsRelativePath( + startupSettings.GetValueOrDefault("REGFILEPATH"), "REGFILEPATH"); + IReadOnlyList saveComponents = ParseWindowsRelativePath( + startupSettings.GetValueOrDefault("SAVEPATH"), "SAVEPATH"); + if (settingsComponents.Count > saveComponents.Count + || !settingsComponents + .Select((component, index) => string.Equals( + component, saveComponents[index], StringComparison.OrdinalIgnoreCase)) + .All(matches => matches)) + { + throw new InvalidDataException( + "SAVEPATH is not beneath REGFILEPATH; one profile-root override cannot safely " + + "represent both native AGE persistence locations."); + } + + string root = Path.GetFullPath(profileRoot); + string saveDirectory = saveComponents + .Skip(settingsComponents.Count) + .Aggregate(root, Path.Combine); + return new Sys4PersistencePaths( + saveDirectory, + Path.Combine(root, Sys4RegIniStore.FileName)); + } + + private static string CombineWindowsRelativePath( + string root, + string? relative, + string settingName) + => ParseWindowsRelativePath(relative, settingName) + .Aggregate(Path.GetFullPath(root), Path.Combine); + + private static IReadOnlyList ParseWindowsRelativePath( + string? relative, + string settingName) + { + string[] components = (relative ?? "").Split( + ['\\', '/'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (string component in components) + { + if (component is "." or ".." || component.Contains(':')) + throw new InvalidDataException( + $"{settingName} contains unsafe component '{component}'."); + } + return components; + } +} diff --git a/engine/Age.Engine/Persistence/Sys4RegIniStore.cs b/engine/Age.Engine/Persistence/Sys4RegIniStore.cs new file mode 100644 index 0000000..c8e08c7 --- /dev/null +++ b/engine/Age.Engine/Persistence/Sys4RegIniStore.cs @@ -0,0 +1,278 @@ +using System.Globalization; +using System.Text; +using Age.Engine.Model; +using Age.Engine.Sys4; + +namespace Age.Engine.Persistence; + +/// +/// Preserving reader/writer for AGE's native SYS4REG.INI engine-settings file. The current projection +/// owns only the audio keys; all other sections, keys, comments, ordering, and newline style survive. +/// +public sealed class Sys4RegIniStore +{ + public const string FileName = "SYS4REG.INI"; + private const string SoundSection = "sound"; + private static readonly Encoding Cp932 = CreateCp932(); + private static readonly string[] RouteKeys = ["", "Music", "SE", "Voice", "Movie"]; + + private readonly string _path; + private readonly object _lock = new(); + private readonly int _defaultMusicRouteValue; + private readonly int[] _routeValues = [0, 0, 1, 1, 1]; + private IniDocument? _document; + + public Sys4RegIniStore(string path, int defaultMusicRouteValue) + { + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Settings path is required.", nameof(path)); + _path = Path.GetFullPath(path); + _defaultMusicRouteValue = defaultMusicRouteValue; + _routeValues[(int)AudioMixerCategory.Music] = defaultMusicRouteValue; + } + + public string FilePath => _path; + + /// + /// Create a store at an already-resolved location while retaining SYS4INI's native defaults. + /// Path policy belongs to Sys4PersistencePaths so save and settings locations are selected together. + /// + public static Sys4RegIniStore ForPath( + Sys4StartupSettings startupSettings, + string path) + { + ArgumentNullException.ThrowIfNull(startupSettings); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + int defaultMusic = int.TryParse( + startupSettings.GetValueOrDefault("NOSETMUSIC"), + NumberStyles.Integer, CultureInfo.InvariantCulture, out int noSetMusic) + && noSetMusic != 0 + ? noSetMusic - 1 + : 0; + return new Sys4RegIniStore(path, defaultMusic); + } + + public AudioMixerSettings Load() + { + var settings = new AudioMixerSettings(); + lock (_lock) + { + _document = LoadDocument(); + int[] volumes = Enumerable.Repeat( + AudioMixerSettings.UnconfiguredVolume, AudioMixerSettings.CategoryCount).ToArray(); + bool[] routes = [false, true, true, true, true]; + + for (int category = 0; category < AudioMixerSettings.CategoryCount; category++) + { + if (_document.TryGetInt(SoundSection, $"Volume{category}", out int value) + && value is >= AudioMixerSettings.UnconfiguredVolume + and <= AudioMixerSettings.MaximumVolume) + volumes[category] = value; + } + + _routeValues[(int)AudioMixerCategory.Music] = _document.TryGetInt( + SoundSection, RouteKeys[(int)AudioMixerCategory.Music], out int music) + ? music + : _defaultMusicRouteValue; + routes[(int)AudioMixerCategory.Music] = + _routeValues[(int)AudioMixerCategory.Music] >= 0; + for (int category = (int)AudioMixerCategory.SoundEffect; + category < AudioMixerSettings.CategoryCount; + category++) + { + _routeValues[category] = _document.TryGetInt( + SoundSection, RouteKeys[category], out int value) + ? value + : 1; + routes[category] = _routeValues[category] != 0; + } + settings.Replace(new AudioMixerSettingsSnapshot(volumes, routes)); + } + return settings; + } + + public void Save(AudioMixerSettingsSnapshot snapshot) + { + AudioMixerSettings.ValidateSnapshot(snapshot); + lock (_lock) + { + _document ??= LoadDocument(); + for (int category = (int)AudioMixerCategory.Music; + category < AudioMixerSettings.CategoryCount; + category++) + { + bool wasEnabled = category == (int)AudioMixerCategory.Music + ? _routeValues[category] >= 0 + : _routeValues[category] != 0; + bool enabled = snapshot.Routes[category]; + if (wasEnabled != enabled) + { + _routeValues[category] = category == (int)AudioMixerCategory.Music + ? checked(_routeValues[category] + (enabled ? 3 : -3)) + : enabled ? 1 : 0; + } + _document.SetInt(SoundSection, RouteKeys[category], _routeValues[category]); + } + for (int category = 0; category < AudioMixerSettings.CategoryCount; category++) + _document.SetInt(SoundSection, $"Volume{category}", snapshot.Volumes[category]); + + string? directory = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + string temporary = _path + ".$tmp"; + File.WriteAllText(temporary, _document.Serialize(), Cp932); + File.Move(temporary, _path, overwrite: true); + } + } + + private IniDocument LoadDocument() + => File.Exists(_path) + ? IniDocument.Parse(File.ReadAllText(_path, Cp932)) + : IniDocument.Empty(); + + private static Encoding CreateCp932() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return Encoding.GetEncoding(932); + } + + private sealed class IniDocument + { + private readonly List _lines; + private readonly string _newline; + private readonly bool _trailingNewline; + + private IniDocument(List lines, string newline, bool trailingNewline) + { + _lines = lines; + _newline = newline; + _trailingNewline = trailingNewline; + } + + public static IniDocument Empty() => new([], "\r\n", true); + + public static IniDocument Parse(string text) + { + if (text.Length == 0) return Empty(); + string newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" + : text.Contains('\n') ? "\n" + : text.Contains('\r') ? "\r" + : "\r\n"; + bool trailing = text.EndsWith("\r\n", StringComparison.Ordinal) + || text.EndsWith('\n') || text.EndsWith('\r'); + string normalized = text.Replace("\r\n", "\n").Replace('\r', '\n'); + var lines = normalized.Split('\n').ToList(); + if (trailing && lines.Count > 0 && lines[^1].Length == 0) lines.RemoveAt(lines.Count - 1); + return new IniDocument(lines, newline, trailing); + } + + public bool TryGetInt(string section, string key, out int value) + { + value = default; + bool found = false; + string? currentSection = null; + foreach (string line in _lines) + { + if (TryParseSection(line, out string? parsedSection)) + { + currentSection = parsedSection; + continue; + } + if (!string.Equals(currentSection, section, StringComparison.OrdinalIgnoreCase) + || !TryParseKey(line, out string? parsedKey, out string? rawValue) + || !string.Equals(parsedKey, key, StringComparison.OrdinalIgnoreCase)) + continue; + if (int.TryParse(rawValue!.Trim(), NumberStyles.Integer, + CultureInfo.InvariantCulture, out int parsed)) + { + value = parsed; + found = true; + } + } + return found; + } + + public void SetInt(string section, string key, int value) + { + string rendered = value.ToString(CultureInfo.InvariantCulture); + int sectionStart = -1; + int sectionEnd = _lines.Count; + string? currentSection = null; + bool replaced = false; + + for (int index = 0; index < _lines.Count; index++) + { + string line = _lines[index]; + if (TryParseSection(line, out string? parsedSection)) + { + if (sectionStart >= 0) + { + sectionEnd = index; + break; + } + currentSection = parsedSection; + if (string.Equals(currentSection, section, StringComparison.OrdinalIgnoreCase)) + sectionStart = index; + continue; + } + if (sectionStart < 0 + || !string.Equals(currentSection, section, StringComparison.OrdinalIgnoreCase) + || !TryParseKey(line, out string? parsedKey, out _) + || !string.Equals(parsedKey, key, StringComparison.OrdinalIgnoreCase)) + continue; + int equals = line.IndexOf('='); + _lines[index] = line[..(equals + 1)] + rendered; + replaced = true; + } + + if (replaced) return; + if (sectionStart < 0) + { + if (_lines.Count > 0 && _lines[^1].Length != 0) _lines.Add(""); + _lines.Add($"[{section}]"); + _lines.Add($"{key}={rendered}"); + } + else + { + _lines.Insert(sectionEnd, $"{key}={rendered}"); + } + } + + public string Serialize() + { + string text = string.Join(_newline, _lines); + return _trailingNewline ? text + _newline : text; + } + + private static bool TryParseSection(string line, out string? section) + { + string trimmed = line.Trim(); + if (trimmed.Length >= 2 && trimmed[0] == '[' && trimmed[^1] == ']') + { + section = trimmed[1..^1].Trim(); + return true; + } + section = null; + return false; + } + + private static bool TryParseKey(string line, out string? key, out string? value) + { + string trimmed = line.TrimStart(); + if (trimmed.Length == 0 || trimmed[0] is ';' or '#') + { + key = null; + value = null; + return false; + } + int equals = line.IndexOf('='); + if (equals < 0) + { + key = null; + value = null; + return false; + } + key = line[..equals].Trim(); + value = line[(equals + 1)..]; + return key.Length != 0; + } + } +} diff --git a/engine/Age.Engine/Vm/GameSession.cs b/engine/Age.Engine/Vm/GameSession.cs index f2e53f9..54624ac 100644 --- a/engine/Age.Engine/Vm/GameSession.cs +++ b/engine/Age.Engine/Vm/GameSession.cs @@ -28,13 +28,17 @@ public sealed class GameSession public SharedProfile SharedProfile { get; } /// Native shared/numbered save directory service used by persistence opcodes. public INativeDatStore? NativeDatStore { get; } + /// AGE's profile-lifetime sound:* settings registry, independent of SAVE.DAT. + public AudioMixerSettings AudioMixerSettings { get; } /// The live retained ADV backlog shared by every VM run in this session. public AdvTextHistory TextHistory { get; } = new(); - public GameSession(SharedProfile? sharedProfile = null, INativeDatStore? nativeDatStore = null) + public GameSession(SharedProfile? sharedProfile = null, INativeDatStore? nativeDatStore = null, + AudioMixerSettings? audioMixerSettings = null) { SharedProfile = sharedProfile ?? new SharedProfile(); NativeDatStore = nativeDatStore; + AudioMixerSettings = audioMixerSettings ?? new AudioMixerSettings(); } public void Seed(int addr, long value) => Globals[addr] = value; @@ -46,7 +50,8 @@ public sealed class GameSession ITraceSink? sink = null) { var vm = new VirtualMachine( - script, table, host, options, provider, sink, TextHistory, SharedProfile, NativeDatStore); + script, table, host, options, provider, sink, TextHistory, SharedProfile, NativeDatStore, + AudioMixerSettings); foreach (var kv in Globals) vm.Globals[kv.Key] = kv.Value; foreach (var kv in GlobalFloats) vm.GlobalFloats[kv.Key] = kv.Value; foreach (var kv in GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value; diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 66053fe..c791bd5 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -40,6 +40,7 @@ public sealed class VirtualMachine private readonly Encoding _nativeStringEncoding; private readonly IScriptProvider? _provider; private readonly SharedProfile _sharedProfile; + private readonly AudioMixerSettings _audioMixerSettings; private readonly INativeDatStore? _nativeDatStore; private static readonly bool _diagSetTexture = System.Environment.GetEnvironmentVariable("AGE_DIAG_SETTEX") == "1"; private ExecFrame _cur = null!; @@ -149,13 +150,15 @@ public sealed class VirtualMachine public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null, IScriptProvider? provider = null, ITraceSink? sink = null, AdvTextHistory? textHistory = null, SharedProfile? sharedProfile = null, - INativeDatStore? nativeDatStore = null) + INativeDatStore? nativeDatStore = null, + AudioMixerSettings? audioMixerSettings = null) { _s = s; _t = t; _host = host; _o = o ?? new VmOptions(); _provider = provider; Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); _nativeStringEncoding = Encoding.GetEncoding(_o.NativeStringCodePage); _sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory(); _sharedProfile = sharedProfile ?? new SharedProfile(); + _audioMixerSettings = audioMixerSettings ?? new AudioMixerSettings(); _nativeDatStore = nativeDatStore; _messageWindowAlphaSetting = host.MessageWindowAlphaSetting; _messageGlyphDelayMilliseconds = System.Math.Max(0, host.MessageGlyphDelayMilliseconds); @@ -2419,6 +2422,46 @@ public sealed class VirtualMachine if (targetPercent == 0) _currentBgmTrackId = 0; return pc + 1; } + case "get-audio-volume": // 0xc5 (category)(out basis points) + { + int category = unchecked((int)Read(a[0])); + if (_audioMixerSettings.TryGetVolume(category, out int basisPoints)) + Write(a[1], basisPoints); + else + _host.ReportWarning($"audio volume category out of range: {category}"); + return pc + 1; + } + case "set-audio-volume": // 0xc6 (category)(basis points) + { + int category = unchecked((int)Read(a[0])); + long basisPoints = Read(a[1]); + if (_audioMixerSettings.TrySetVolume(category, basisPoints)) + _host.ApplyAudioVolume(category, checked((int)basisPoints)); + else + _host.ReportWarning($"audio volume category out of range: {category}"); + return pc + 1; + } + case "get-audio-route-enabled": // 0xc7 (category)(out boolean) + { + int category = unchecked((int)Read(a[0])); + if (_audioMixerSettings.TryGetRouteEnabled(category, out bool enabled)) + Write(a[1], enabled ? 1 : 0); + else + _host.ReportWarning($"audio route category out of range: {category}"); + return pc + 1; + } + case "set-audio-route-enabled": // 0x1ba (category)(enabled) + { + int category = unchecked((int)Read(a[0])); + bool enabled = Read(a[1]) != 0; + if (_audioMixerSettings.TrySetRouteEnabled(category, enabled, out bool changed)) + { + if (changed) _host.ApplyAudioRouteEnabled(category, enabled); + } + else + _host.ReportWarning($"audio route category out of range: {category}"); + return pc + 1; + } case "u00415880": // 0xd9 / semantics: clear-run-state-0x1000 return pc + 1; case "get-initial-root-run": // 0x130 (out) diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index e13f38b..d939f46 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -1672,6 +1672,26 @@ public sealed class GodotAdvHost : IHost } _timeline?.State("running", new() { ["bgm_fade_complete"] = true }); } + + public void ApplyAudioVolume(int category, int basisPoints) + { + _timeline?.State("audio-volume", new() + { + ["category"] = category, + ["basis_points"] = basisPoints, + }); + _main.CallDeferred("ApplyAudioVolume", category, basisPoints); + } + + public void ApplyAudioRouteEnabled(int category, bool enabled) + { + _timeline?.State("audio-route", new() + { + ["category"] = category, + ["enabled"] = enabled, + }); + _main.CallDeferred("ApplyAudioRouteEnabled", category, enabled); + } } public readonly record struct SurfaceTextDraw(int X, int Y, string Text, AdvTextStyle Style); diff --git a/godot/Main.cs b/godot/Main.cs index 8035a71..94f1f10 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; @@ -63,6 +65,8 @@ public partial class Main : Godot.Control private float _voiceBgmDuckRestoreDb; private readonly AudioStreamPlayer[] _sfx = new AudioStreamPlayer[10]; // SC0000 channels 0..9 private readonly int[] _sfxGenerations = new int[10]; + private AudioMixerSettings _audioMixerSettings = null!; + private Sys4RegIniStore? _sys4RegIniStore; private VirtualMachine _vm = null!; private GodotAdvHost _host = null!; private Sys4ScriptProvider? _scripts; @@ -304,16 +308,37 @@ public partial class Main : Godot.Control GD.Print($"[renderer] retained backend={(_useGpuBackend ? "gpu" : "software")}"); var table = OpcodeTableJson.Load(Paths.OpcodesJson); - // Persistence opcodes retain AGE's native filenames and binary formats, but the port owns the - // root interception point. Keep authored saves isolated from the original installation under - // Godot's per-application user directory. + // Persistence retains AGE's native filenames and formats, but the port owns one profile-root + // interception point. Himegari's SYS4INI makes SAVEPATH the SAVE child of REGFILEPATH, so both + // save payloads and SYS4REG.INI remain isolated together under Godot's user directory. + Sys4PersistencePaths persistencePaths = Sys4PersistencePaths.ResolveProfileOverride( + catalog.StartupSettings, + ProjectSettings.GlobalizePath("user://")); var nativeSaveStore = new DirectoryNativeDatStore( - ProjectSettings.GlobalizePath("user://SAVE"), + persistencePaths.SaveDirectory, new NativeSaveIdentity( NativeSaveMagic.S4SD, 0x4a343234, "姫狩りダンジョンマイスター", SaveVersion1: 3, SaveVersion2: 10, NumberedCompatibilityId: 0x42323234)); var sharedProfile = new SharedProfile(); if (!_selftest) sharedProfile.Load(nativeSaveStore); + _audioMixerSettings = new AudioMixerSettings(); + if (!_selftest) + { + try + { + _sys4RegIniStore = Sys4RegIniStore.ForPath( + catalog.StartupSettings, persistencePaths.Sys4RegIniPath); + _audioMixerSettings = _sys4RegIniStore.Load(); + GD.Print($"[settings] native engine options={_sys4RegIniStore.FilePath}"); + } + catch (Exception error) when ( + error is IOException or UnauthorizedAccessException or InvalidDataException) + { + GD.PushWarning($"[settings] audio mixer load failed; using native defaults: {error.Message}"); + } + _audioMixerSettings.Changed += PersistAudioMixerSettings; + } + ApplyAudioMixerSnapshot(_audioMixerSettings.Snapshot()); // Full op handling everywhere: the provider lets call-script load & run subroutines. Selftest // runs a SYNTHESIZED scene (not a real scene in a crippled mode) so its output is deterministic. @@ -369,7 +394,8 @@ public partial class Main : Godot.Control NoSaveDat: _selftest), provider, sink, sharedProfile: sharedProfile, - nativeDatStore: nativeSaveStore); + nativeDatStore: nativeSaveStore, + audioMixerSettings: _audioMixerSettings); if (scripts != null) { _debugSceneEntries = DebugSceneCatalog.Build(scripts.Catalog); @@ -1803,6 +1829,85 @@ public partial class Main : Godot.Control _bgm.Play(); } + private void PersistAudioMixerSettings(AudioMixerSettingsSnapshot snapshot) + { + try + { + _sys4RegIniStore?.Save(snapshot); + } + catch (Exception error) when ( + error is IOException or UnauthorizedAccessException or InvalidDataException) + { + System.Console.Error.WriteLine( + $"[settings] audio mixer save failed; runtime setting remains active: {error.Message}"); + } + } + + private void ApplyAudioMixerSnapshot(AudioMixerSettingsSnapshot snapshot) + { + for (int category = 0; category < AudioMixerSettings.CategoryCount; category++) + ApplyAudioVolume(category, snapshot.Volumes[category]); + for (int category = (int)AudioMixerCategory.Music; + category < AudioMixerSettings.CategoryCount; + category++) + SetAudioBusMuted(category, !snapshot.Routes[category]); + } + + public void ApplyAudioVolume(int category, int basisPoints) + { + string? busName = AudioBusName(category); + if (busName == null) return; + int bus = AudioServer.GetBusIndex(busName); + if (bus < 0) return; + int effectiveBasisPoints = basisPoints < 0 + ? AudioMixerSettings.MaximumVolume + : System.Math.Clamp(basisPoints, 0, AudioMixerSettings.MaximumVolume); + float linear = effectiveBasisPoints / (float)AudioMixerSettings.MaximumVolume; + AudioServer.SetBusVolumeDb(bus, linear <= 0 ? -80.0f : Mathf.LinearToDb(linear)); + } + + public void ApplyAudioRouteEnabled(int category, bool enabled) + { + if (category == (int)AudioMixerCategory.Music) + { + RestoreVoiceBgmDuck(); + CancelBgmFade(); + if (enabled) + { + if (_bgm.Stream != null) _bgm.Play(); + } + else + _bgm.Stop(); + } + else if (!enabled && category == (int)AudioMixerCategory.SoundEffect) + { + CancelScheduledSoundEffectStarts(); + foreach (AudioStreamPlayer player in _sfx) player.Stop(); + } + else if (!enabled && category == (int)AudioMixerCategory.Voice) + StopVoiceForMessageSkip(); + + SetAudioBusMuted(category, !enabled); + } + + private static void SetAudioBusMuted(int category, bool muted) + { + string? busName = AudioBusName(category); + if (busName == null) return; + int bus = AudioServer.GetBusIndex(busName); + if (bus >= 0) AudioServer.SetBusMute(bus, muted); + } + + private static string? AudioBusName(int category) => category switch + { + (int)AudioMixerCategory.Master => "Master", + (int)AudioMixerCategory.Music => "Music", + (int)AudioMixerCategory.SoundEffect => "SFX", + (int)AudioMixerCategory.Voice => "Voice", + (int)AudioMixerCategory.Movie => "Movie", + _ => null, + }; + public int QueueVoicePlayback() => System.Threading.Interlocked.Increment(ref _voiceQueuedGeneration); diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 68d509c..6856568 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -1983,6 +1983,7 @@ source = "investigation" confidence = "high" depends_on = [] evidence = "Ghidra /v2: op_0xc5_get_audio_volume@0x428470 selects sound:Volume0..Volume4 for operand 1 values 0..4, queries the engine settings registry, and writes the result to operand 2. CONFIG.BIN uses the results to draw and adjust four visible category sliders; its initialization also sets Volume0..Volume3 to 10000/6500/5000/8000." +details = "Port status (2026-07-29): implemented against the profile-lifetime AudioMixerSettings registry. The native -1 unconfigured sentinel is preserved; CONFIG reads receive the exact stored basis-point value." [[opcode.semantics.args]] i = 1 @@ -2009,6 +2010,7 @@ source = "investigation" confidence = "high" depends_on = [] evidence = "Ghidra /v2: op_0xc6_set_audio_volume@0x4206d0 writes sound:Volume0..Volume4 through the settings registry and dispatches selectors 0..4 to the live master/music/SFX/voice/movie volume workers. The master worker reapplies dependent volume to active movies and sound buffers; category workers update active BGM, ten SFX channels, voice channel 12, or the movie basis respectively." +details = "Port status (2026-07-29): implemented with native SYS4REG.INI [sound] persistence and live Godot Master/Music/SFX/Voice/Movie bus gain changes. The CP932 preserving writer retains unrelated options; master and category buses compose multiplicatively like native basis-point application." [[opcode.semantics.args]] i = 1 @@ -2035,6 +2037,7 @@ source = "investigation" confidence = "high" depends_on = [] evidence = "Ghidra /v2: op_0xc7_get_audio_route_enabled@0x4285a0 queries sound:Music/SE/Voice/Movie for selectors 1..4 and writes boolean 0/1 to operand 2. CONFIG.BIN's only site loops selectors 1..3 to choose enabled/disabled presentation." +details = "Port status (2026-07-29): implemented as the read half of the shared audio-route registry loaded from native SYS4REG.INI." [[opcode.semantics.args]] i = 1 @@ -4075,6 +4078,7 @@ source = "investigation" confidence = "high" depends_on = [] evidence = "Ghidra /v2: op_0x1ba_set_audio_route_enabled@0x420860 dispatches selectors 1..4 to the music/SFX/voice/movie enable workers. Those workers update the live route, stop active playback when disabling where applicable, write sound:Music/SE/Voice/Movie through the settings registry, and reject selectors outside 1..4. All 15 CONFIG.BIN sites pass literal 0/1." +details = "Port status (2026-07-29): implemented with native SYS4REG.INI route persistence and live bus muting. AGE's raw music value band is preserved (Himegari 2 enabled toggles to -1 disabled); music changes restart/stop the retained stream, disabling SFX or voice stops active playback, and movie changes remain non-destructive." [[opcode.semantics.args]] i = 1