diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index f9953423..809181e6 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -32,6 +32,11 @@ struct ModOption { int64_t max_value = 0; int64_t step = 1; std::vector choices; + /* Optional id of a BOOLEAN option in the same feature that overrides this + * one. While that option is true this control is inert: the launcher greys + * it out and plugins ignore its value. Models "tick Instant and the speed + * box stops mattering" without inventing a second widget type. */ + std::string disabled_by; }; struct ModFeature { diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 2cdb6ee8..610f245d 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -123,10 +123,20 @@ enum { int psx_mod_set_frame_interpolation_blend(uint32_t blend_mode); int psx_mod_set_auto_skip_fmv(int enabled); +/* + * Upper bounds for the two loading-speed knobs below. Both are generous on + * purpose: games surface them to players as free-form integers, and neither + * can corrupt guest state (see the notes on each setter). They exist to reject + * nonsense, not to curate a list of "blessed" speeds. + */ +#define PSX_MOD_LOAD_ACCEL_MAX 1024u +#define PSX_MOD_DISC_SPEED_MAX 1024u + /* * Accelerate only the wall-clock pacing of sustained non-XA data loads while * preserving every guest VBlank, CD deadline, interrupt, and callback. - * wall_clock_multiplier accepts 2..16, or zero for uncapped host speed. + * wall_clock_multiplier accepts 1..PSX_MOD_LOAD_ACCEL_MAX, or zero for + * uncapped host speed (1 is a no-op, i.e. authentic pacing). * release_frames controls how many guest frames acceleration may remain active * after the load predicate clears; zero is the precise/speedrun-safe policy. */ @@ -134,10 +144,15 @@ int psx_mod_set_load_acceleration(uint32_t wall_clock_multiplier, uint32_t release_frames); /* - * Select guest-visible CD timing for a game-owned loading feature. divisor is - * 2 or 4, or zero for the bounded "instant" scheduler; instant_max_per_frame - * is used only with divisor zero. Unlike host load acceleration, this changes - * when the emulated game receives CD interrupts and can expose timing bugs. + * Select guest-visible CD timing for a game-owned loading feature. divisor + * divides the emulated sector delay, so it IS the speed multiplier: 1 is + * authentic timing, higher is faster, up to PSX_MOD_DISC_SPEED_MAX. Zero + * selects the bounded "instant" scheduler, and instant_max_per_frame (1..256) + * applies only in that case. cdrom.c floors the divided delay at + * CDROM_MIN_DELAY and leaves XA streaming at authentic timing, so no value + * here can produce a zero-delay storm or speed up FMV audio. Unlike host load + * acceleration this changes WHEN the guest receives CD interrupts and can + * expose game timing bugs, which is why it is a separate, opt-in knob. */ int psx_mod_set_disc_speed(uint32_t divisor, uint32_t instant_max_per_frame); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index cc1869ed..98ba063e 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -801,9 +801,12 @@ extern "C" int psx_mod_set_auto_skip_fmv(int enabled) { extern "C" int psx_mod_set_load_acceleration( uint32_t wall_clock_multiplier, uint32_t release_frames) { - if ((wall_clock_multiplier != 0 && - (wall_clock_multiplier < 2 || wall_clock_multiplier > 16)) || - release_frames > 60) { + /* Host pacing only changes how fast wall-clock time is fed to a load; every + * guest frame, CD deadline, interrupt and callback still happens, so a high + * multiplier cannot desync the guest -- it just approaches "as fast as the + * host can". Ceiling raised from 16 for the same reason as disc speed: + * players set this as an integer and want to find their own limit. */ + if (wall_clock_multiplier > PSX_MOD_LOAD_ACCEL_MAX || release_frames > 60) { std::fprintf(stderr, "psxrecomp: mod rejected load acceleration " "(multiplier %u, release frames %u)\n", @@ -817,7 +820,14 @@ extern "C" int psx_mod_set_load_acceleration( extern "C" int psx_mod_set_disc_speed( uint32_t divisor, uint32_t instant_max_per_frame) { - if ((divisor != 0 && divisor != 2 && divisor != 4) || + /* Any positive divisor is arithmetically safe: cdrom.c divides the sector + * delay by it and floors the result at CDROM_MIN_DELAY, and XA streaming + * keeps authentic timing regardless. The old 2-or-4 allowlist was policy, + * not a hardware constraint, and it blocked players from finding the + * highest speed their game tolerates (GH TombaRecomp#5). Games expose the + * value as a player-set integer, so accept the full sane range and let the + * mod's own description carry the risk warning. */ + if (divisor > PSX_MOD_DISC_SPEED_MAX || (divisor == 0 && (instant_max_per_frame < 1 || instant_max_per_frame > 256))) { std::fprintf(stderr, diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index a131a971..475c4318 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -1514,6 +1514,8 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, option.description = v.contains("description") ? toml::find(v, "description") : ""; option.group = v.contains("group") ? toml::find(v, "group") : "General"; + option.disabled_by = + v.contains("disabled_by") ? toml::find(v, "disabled_by") : ""; const std::string type = toml::find(v, "type"); if (!find_feature(out, option.feature_id)) throw std::runtime_error("option references unknown feature"); @@ -1555,6 +1557,26 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, } out.options.push_back(std::move(option)); } + /* Resolve disabled_by AFTER the whole list is read, so it may name + * an option declared later. A dangling or non-boolean reference is + * a manifest bug that would silently leave the control always + * enabled, so reject it here rather than at render time. */ + for (const ModOption& option : out.options) { + if (option.disabled_by.empty()) continue; + if (option.disabled_by == option.id) + throw std::runtime_error("option disabled_by references itself"); + const auto owner = std::find_if( + out.options.begin(), out.options.end(), + [&](const ModOption& o) { + return o.feature_id == option.feature_id && + o.id == option.disabled_by; + }); + if (owner == out.options.end() || + owner->type != ModOptionType::Boolean) + throw std::runtime_error( + "option disabled_by must name a boolean option " + "in the same feature"); + } } if (cfg.contains("constraint")) { if (out.format_version < 3) diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index d2d9e607..85c838bd 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -107,6 +107,22 @@ std::string selected_value(const ModPackage& package, const ModOption& option) { return option.default_value; } +/* Resolve the manifest's disabled_by link against the CURRENT selection: the + * named boolean sibling being true makes this option inert. Both the launcher + * (greys the control) and psx_mod_option_value (returns the default instead of + * a stale value) go through this, so the UI and the plugins can never disagree + * about whether a control counts. */ +bool option_is_disabled(const ModPackage& package, const ModOption& option) { + if (option.disabled_by.empty()) return false; + for (const ModOption& other : package.options) { + if (other.feature_id != option.feature_id || + other.id != option.disabled_by) + continue; + return selected_value(package, other) == "true"; + } + return false; +} + void build_disc_index(RuntimeMods& s) { s.raw_disc_index.clear(); s.user_disc_index.clear(); @@ -562,6 +578,7 @@ int provider_option_get(void*, const char* package_id, int index, out->max_value = option.max_value; out->step = option.step; out->choice_count = (int)option.choices.size(); + out->disabled = option_is_disabled(*package, option) ? 1 : 0; return 1; } @@ -682,6 +699,7 @@ int provider_feature_option_get(void*, const char* package_id, out->max_value = option.max_value; out->step = option.step; out->choice_count = (int)option.choices.size(); + out->disabled = option_is_disabled(*package, option) ? 1 : 0; return 1; }