Skip to content

fix(base): an Option<const char*> owns the string it is given - #15

Merged
Force67 merged 1 commit into
devel5from
fix/option-owns-its-string
Sep 16, 2026
Merged

Force67 merged 1 commit into
devel5from
fix/option-owns-its-string

Conversation

@Force67

@Force67 Force67 commented Sep 16, 2026

Copy link
Copy Markdown
Owner

SetFromString stored the caller's pointer, so an Option<const char*> was only
as alive as the buffer it was parsed out of. InitOptionsFromEnv reads each
variable into a local StringU8 and hands over its c_str():

base::StringU8 value;                       // dies at the end of the iteration
if (base::GetEnvironmentVariable(..., value))
  option->SetFromString(reinterpret_cast<const char*>(value.c_str()));

Every string option set from the environment has pointed at freed memory since
base stopped calling getenv (#14).

It does not read as a crash

rx takes its screenshot path from RX_UI_SHOT=<path>. With the dangling value
the engine did not skip the capture — it wrote a 51 KB png to a filename made of
whatever the freed bytes spelled, which landed in the repository root as
@\2351\364\377\377. A knob like REC_TERRAIN_EDITS decides where terrain
edits are saved. The failure mode is an arbitrary path, not an error.

Where the ownership belongs

Three call sites hand SetFromString a value:

caller gets it right?
option_file.cc yes — it interns every value into a pool that outlives the options (InternValue)
InitOptionsFromEnv no — a local that dies one line later
recreation's platform-profile loader no — value.c_str() of a std::string parameter

Two of three holding it wrong is an API pointing at its own answer: the option
should own its string. ParseFromString now copies into a buffer the option
keeps, Reset() drops it again, and any caller can pass whatever it has at
hand. detail::OwnedText<T> is empty for every other T and sits behind
[[no_unique_address]], so an Option<int> is the size it always was — there
is a test for that.

The const char* branch of detail::ParseOption is gone with it; nothing
reaches it any more, and leaving a second, binding-not-copying path in place is
how this comes back.

Tests

base/option_test.cc is new — the option machinery itself had none, which is
how #14 shipped. It covers parsing per type, a value outliving the buffer it
came from, the same through the environment, Reset(), a rejected parse leaving
the previous value in place, and the storage claim above. Three of the six fail
on devel5 without this change:

[  FAILED  ] OptionTest.StringValueOutlivesTheCallersBuffer
[  FAILED  ] OptionTest.EnvironmentOverrideOutlivesTheRead
[  FAILED  ] OptionTest.RejectsWhatDoesNotParse

All four of the suite's existing test binaries pass with it.

SetFromString stored the caller's pointer, so a string option was only
as alive as whatever buffer it was parsed out of. InitOptionsFromEnv
reads each variable into a local StringU8 and hands over its c_str(),
which dies at the end of the loop iteration: every const char* option
set from the environment has pointed at freed memory since base stopped
calling getenv (#14).

It does not read as a crash. rx takes its capture path from
RX_UI_SHOT=<path>, and with the dangling value the engine wrote a 51 KB
png to a filename made of whatever the freed bytes happened to spell.
Whoever calls SetFromString next gets a different kind of wrong.

The other two callers show the shape of the trap. option_file.cc knew,
and interns every value into a pool that outlives the options
(InternValue). recreation's platform-profile loader did not, and passes
the c_str() of a std::string that goes out of scope one line later.
Three call sites, two of them holding it wrong, is an API telling you
where the ownership belongs: with the option.

ParseFromString now copies into a buffer the option keeps, so a value
survives its source, Reset() drops it again, and callers can pass
anything they have at hand. Only const char* options carry the buffer --
detail::OwnedText is empty for every other T -- so an Option<int> is the
size it always was.

base/option_test.cc covers what nothing covered: parsing per type, a
value outliving the buffer it came from, the same through the
environment, Reset, and a rejected parse leaving the old value in place.
Three of the six fail without this change.
@Force67
Force67 merged commit f961dcb into devel5 Sep 16, 2026
6 checks passed
Force67 added a commit to Force67/recreation that referenced this pull request Sep 16, 2026
Retarget the build onto rx main (2198552), which moved its ultragui
integration out of apps/editor into an rx::ui module and put an engine
splash on it. rx::app links that module, so the plate needs nothing from
the game -- but libultragui stops being optional, and the engine now owns
three files and six shaders recreation carried its own copies of.

libultragui is configured ahead of rx: rx::ui adds the checkout itself
when no ultragui target exists yet, and that copy would carry ultragui's
Lua scripting instead of recreation's C# host (adding the same source
directory twice is a hard error anyway). The platform and RHI shims now
come from the engine, as do the ugui pipeline shaders -- both repos
embedded those six blobs to the same generated header path, which ninja
refuses outright. RX_LIBULTRAGUI_DIR is forwarded for the builds that
compile no HUD of their own; android checks the library out for that.

The HUD's own Vulkan backend stays, renamed HudRenderBackend so it no
longer collides with rx::ui::GuiRenderBackend. It keeps the growing
descriptor pool chain a vanilla Scaleform screen's art needs, which the
engine's single fixed block does not hold; it can go when that lands in
rx::ui.

The flake stops reading the siblings out of one developer's home
directory. All six (rx, zetanet, nanobuf, libultragui, kinema,
equilibrium) are fetched from github at the revisions build.yml pins, with submodules
wherever CI checks them out recursively, so the sandbox and CI compile
the same engine on any machine. What git does not carry does not arrive:
rx's FidelityFX/DLSS/NRD/Jolt SDKs are downloads, so that build
configures with those features off and a working copy stays the way to
build with them.

equilibrium moves to the head of devel5, the branch .gitmodules names
and the pin had drifted off: a commit on no branch at all, five behind
devel5 and one ahead, reachable by sha alone. devel5 brings the STL out
of base's production surface, the security pass behind it, and a string
option that owns its value instead of pointing at the buffer it was
parsed from (Force67/equilibrium#15) -- the bug that had RX_UI_SHOT
writing captures to a filename made of freed memory.

equilibrium also stops being welded to the submodule. It is where base::
comes from, and it was reachable one way only: third_party/equilibrium,
checked out recursively, which drags in its own eight submodules
(googletest, musl, mbedtls, tracy and the rest) that sit behind
EQ_BUILD_TESTS and never compile here. Every CI job cloned all of them,
and `src = self` carries no submodules at all, so the nix build could
not see equilibrium in the first place. RECREATION_EQUILIBRIUM_DIR now
names the checkout, the submodule stays the default, the sandbox gets a
tarball input at the revision the submodule records, and the checkouts
that only need equilibrium itself ask for one level instead of eight.
With that and slangc (rx compiles .slang shaders now), `nix build`
produces recreation, recreation-server and esminfo for the first time.

The sibling pins move together, because the retarget needed fixes in
three of them. zetanet spells out the <string.h> its bare mem* calls
always needed (Force67/zetanet#2). libultragui can load a font from
memory instead of only from a path (Force67/libultragui#5). rx uses that
for the splash wordmark, which deletes the temp file it used to write,
the pid in its name and the <unistd.h> that came with it -- the include
MSVC does not have, and the reason a windows build of anything linking
rx::ui failed (Force67/rx#60).

Verified against rx 2198552: full build, 107/107 tests, and a windowed
run that shows the plate and hands the frame back to the game's own UI
when it expires. RECREATION_NET=OFF, the no-SDL3 path and an out-of-tree
equilibrium all configure. `nix build .#default` completes.
Force67 added a commit to Force67/recreation that referenced this pull request Sep 16, 2026
Retarget the build onto rx main (2198552), which moved its ultragui
integration out of apps/editor into an rx::ui module and put an engine
splash on it. rx::app links that module, so the plate needs nothing from
the game -- but libultragui stops being optional, and the engine now owns
three files and six shaders recreation carried its own copies of.

libultragui is configured ahead of rx: rx::ui adds the checkout itself
when no ultragui target exists yet, and that copy would carry ultragui's
Lua scripting instead of recreation's C# host (adding the same source
directory twice is a hard error anyway). The platform and RHI shims now
come from the engine, as do the ugui pipeline shaders -- both repos
embedded those six blobs to the same generated header path, which ninja
refuses outright. RX_LIBULTRAGUI_DIR is forwarded for the builds that
compile no HUD of their own; android checks the library out for that.

The HUD's own Vulkan backend stays, renamed HudRenderBackend so it no
longer collides with rx::ui::GuiRenderBackend. It keeps the growing
descriptor pool chain a vanilla Scaleform screen's art needs, which the
engine's single fixed block does not hold; it can go when that lands in
rx::ui.

The flake stops reading the siblings out of one developer's home
directory. All six (rx, zetanet, nanobuf, libultragui, kinema,
equilibrium) are fetched from github at the revisions build.yml pins, with submodules
wherever CI checks them out recursively, so the sandbox and CI compile
the same engine on any machine. What git does not carry does not arrive:
rx's FidelityFX/DLSS/NRD/Jolt SDKs are downloads, so that build
configures with those features off and a working copy stays the way to
build with them.

equilibrium moves to the head of devel5, the branch .gitmodules names
and the pin had drifted off: a commit on no branch at all, five behind
devel5 and one ahead, reachable by sha alone. devel5 brings the STL out
of base's production surface, the security pass behind it, and a string
option that owns its value instead of pointing at the buffer it was
parsed from (Force67/equilibrium#15) -- the bug that had RX_UI_SHOT
writing captures to a filename made of freed memory.

equilibrium also stops being welded to the submodule. It is where base::
comes from, and it was reachable one way only: third_party/equilibrium,
checked out recursively, which drags in its own eight submodules
(googletest, musl, mbedtls, tracy and the rest) that sit behind
EQ_BUILD_TESTS and never compile here. Every CI job cloned all of them,
and `src = self` carries no submodules at all, so the nix build could
not see equilibrium in the first place. RECREATION_EQUILIBRIUM_DIR now
names the checkout, the submodule stays the default, the sandbox gets a
tarball input at the revision the submodule records, and the checkouts
that only need equilibrium itself ask for one level instead of eight.
With that and slangc (rx compiles .slang shaders now), `nix build`
produces recreation, recreation-server and esminfo for the first time.

The sibling pins move together, because the retarget needed fixes in
three of them. zetanet spells out the <string.h> its bare mem* calls
always needed (Force67/zetanet#2). libultragui can load a font from
memory instead of only from a path (Force67/libultragui#5). rx uses that
for the splash wordmark, which deletes the temp file it used to write,
the pid in its name and the <unistd.h> that came with it -- the include
MSVC does not have, and the reason a windows build of anything linking
rx::ui failed (Force67/rx#60).

Verified against rx 2198552: full build, 107/107 tests, and a windowed
run that shows the plate and hands the frame back to the game's own UI
when it expires. RECREATION_NET=OFF, the no-SDL3 path and an out-of-tree
equilibrium all configure. `nix build .#default` completes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant