Implement CONFIG mixer and native profile persistence

This commit is contained in:
gamer147
2026-07-29 10:22:22 -04:00
parent 9b3fc89b72
commit 3472692f1c
16 changed files with 1037 additions and 32 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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 ` 明朝`/` ゴシック` 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 |

View File

@@ -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 |
|---|---|---|---|

View File

@@ -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<Operand>());
[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= 明朝\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= 明朝\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);
}
}

View File

@@ -44,6 +44,8 @@ internal class RecordingHost : IHost
public readonly List<int> SfxReleases = new();
public readonly List<long> 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));

View File

@@ -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.
/// <returns>The initialized movie graph's stop position in truncated integer milliseconds, or null

View File

@@ -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);
/// <summary>
/// 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.
/// </summary>
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<AudioMixerSettingsSnapshot>? 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());
}

View File

@@ -0,0 +1,98 @@
using System.Globalization;
using Age.Engine.Sys4;
namespace Age.Engine.Persistence;
/// <summary>
/// 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.
/// </summary>
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<string> settingsComponents = ParseWindowsRelativePath(
startupSettings.GetValueOrDefault("REGFILEPATH"), "REGFILEPATH");
IReadOnlyList<string> 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<string> 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;
}
}

View File

@@ -0,0 +1,278 @@
using System.Globalization;
using System.Text;
using Age.Engine.Model;
using Age.Engine.Sys4;
namespace Age.Engine.Persistence;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
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<string> _lines;
private readonly string _newline;
private readonly bool _trailingNewline;
private IniDocument(List<string> 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;
}
}
}

View File

@@ -28,13 +28,17 @@ public sealed class GameSession
public SharedProfile SharedProfile { get; }
/// <summary>Native shared/numbered save directory service used by persistence opcodes.</summary>
public INativeDatStore? NativeDatStore { get; }
/// <summary>AGE's profile-lifetime sound:* settings registry, independent of SAVE.DAT.</summary>
public AudioMixerSettings AudioMixerSettings { get; }
/// <summary>The live retained ADV backlog shared by every VM run in this session.</summary>
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;

View File

@@ -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)

View File

@@ -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);

View File

@@ -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);

View File

@@ -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