Skip to content

Updates - #16

Merged
jgarzik merged 21 commits into
masterfrom
updates
Oct 10, 2026
Merged

jgarzik merged 21 commits into
masterfrom
updates

Conversation

@jgarzik

@jgarzik jgarzik commented Oct 10, 2026

Copy link
Copy Markdown
Owner

No description provided.

jgarzik and others added 3 commits October 10, 2026 17:13
The runtime is to move from the two hand-written x86-64 assembly trees to
one no_std Rust crate, rt/, so that it is one source for every OS and can
follow the compiler to aarch64. This is its first step; nothing links it
yet, and every program still runs on the assembly.

- rt/ is a workspace member (and a default member, so clippy and cargo
  test cover it). Its unit tests run with std.
- build.rs builds it with rustc for the target: a staticlib with fat LTO,
  emitted as the one object that makes (/rt.o, 14 KB, needing only
  libc). The staticlib archive itself would carry 4 MB of
  compiler_builtins the runtime never references.
- export! defines each helper under its symbol name with the System V
  convention on x86-64 -- on Windows too, so that a string result comes
  back in rax:rdx as Cranelift's two-value return does -- and the C
  convention elsewhere.
- sys holds the libc declarations and what differs by OS: the standard
  streams (glibc, macOS, UCRT) and the line ending.
- error is error.s in Rust: the messages and their GW-BASIC numbers, the
  four trapping words generated code reads by symbol, _rt_error,
  _rt_error_num, _rt_fatal, _rt_err, _rt_erl, _rt_stop and _rt_end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One module per assembly file. num and string declare what the others call
-- scan_number, fmt_double, fmt_basic and strdup -- so that they can be
ported side by side; their bodies are still todo!(). Nothing links rt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…CATE, COLOR, POS

The xorshift64 generator and its splitmix64 seeding, bit for bit (unit
tests hold the sequence the asm runtime prints); TIMER from one clock
reading (gettimeofday, or _ftime64 on Windows) in local time; DATE$ and
TIME$ through strftime, as fresh strings; the console escapes formatted
with snprintf and written to the C library's stdout on every OS.

sys.rs gains a marked block for the clock and struct tm. file.rs gets a
marked stub of console_column / set_console_column, which the file
module's own replaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jgarzik
jgarzik requested a balanced review from Copilot October 10, 2026 17:24
@jgarzik jgarzik self-assigned this Oct 10, 2026
num.rs: fmt_double/fmt_basic (print.s), scan_number and _rt_val.
string.rs: every helper in string.s, slices staying slices and every fresh
result from alloc_str, so a failed allocation now raises Out of memory.
data.rs: READ, RESTORE and array autodim, with the program's DATA symbols
replaced by a table of the tests' own under cfg(test).

The code has no path that can panic: rt.o is emitted before LTO merges in
core, so a failing bounds check would leave core's panic functions as
undefined symbols. sys.rs gains strtod.

Expected text in the tests is what the asm runtime printed. Errors that
are not trapped are tested in a child process, which keeps the trapping
state out of the tests running in parallel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It introduces a foundational, platform-specific no_std runtime with new FFI and a new per-build rustc step whose cross-platform (Linux/macOS/Windows) correctness cannot be fully verified here, warranting human review.

1 open finding
What changed in this PR

This PR begins porting xbasic64's hand-written assembly runtime to a portable no_std Rust crate (rt/). The new crate is added as a workspace member (so clippy and unit tests cover it) and is compiled by build.rs with rustc into $OUT_DIR/rt.o for the target, but is deliberately not yet linked into compiled programs — the assembly runtime (runtime::OBJECT) remains in use until the port is complete and switched over in one step. The first ported areas are errors (error.rs) and the math/console group RND/RANDOMIZE/TIMER/DATE$/TIME$/BEEP/CLS/LOCATE/COLOR/POS (math.rs), backed by a C-library/platform abstraction (sys.rs) and core primitives (lib.rs); the remaining modules are stubs with todo!().

Changes:

  • Add the rt/ no_std runtime crate with Str/Word/State/alloc_str primitives, the export! ABI macro (sysv64 on x86_64), and ported error/math/sys modules plus unit tests; other modules are placeholders.
  • Wire rt/ into the Cargo workspace (Cargo.toml, Cargo.lock, rt/Cargo.toml).
  • Extend build.rs to build rt/ as a fat-LTO staticlib emitted as a single object (rt.o), not yet linked.
File Description
Cargo.toml Declares the workspace with rt as a member and default member.
Cargo.lock Records the new xbasic64-rt package entry.
build.rs Adds build_rt to compile rt/ via rustc into $OUT_DIR/rt.o; watches rt/src.
rt/​Cargo.toml New runtime crate manifest (lib-only, no doctests, publish = false).
rt/​src/​lib.rs Crate root: export! macro, Str/Word/State, cstr, alloc_str, panic handler.
rt/​src/​sys.rs C-library FFI and per-OS stream/clock/Tm abstractions.
rt/​src/​error.rs Error messages, GW-BASIC error codes, raise/trap/fatal, ERR/ERL/ERROR/STOP/END.
rt/​src/​math.rs RND/RANDOMIZE/TIMER/DATE$/TIME$/BEEP/CLS/LOCATE/COLOR/POS with ANSI escapes and tests.
rt/​src/​num.rs Number formatting/scanning API with todo!() stubs.
rt/​src/​string.rs strdup stub.
rt/​src/​file.rs Temporary console-column stub consumed by math.rs.
rt/​src/​data.rs, input.rs, print.rs, using.rs Placeholder module headers.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread build.rs
jgarzik and others added 17 commits October 10, 2026 17:32
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
--emit obj writes the crate's object without the parts of core fat LTO
would merge into it, so any bounds check left core's panic functions as
undefined symbols. rustc now builds the staticlib, whose first member is
that merged object, and build.rs keeps only it -- read straight from the
ar archive (GNU, BSD and COFF long names), so no ar tool is needed -- and
drops the rest, compiler_builtins.

rt.o: 12.7 KB of text; it needs only the C library and the program's
_data_* symbols.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
file.s, input.s and using.s become file.rs, input.rs and using.rs, and
print.s's _rt_platform_init becomes print.rs's, one source for every
target. Every file, and the console as file 0, is a C stdio stream in
binary mode; sys.rs gains the OS differences in one marked block: stdin,
errno, 64-bit seek, record locking, the directory check (the fstat system
call on Linux, so old glibc still links), and why an OPEN failed.

Nothing links it yet; unit tests cover the field readers, the INPUT line
parser, the USING format parser and renderer, and file round trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Windows

Two CI failures in the runtime crate's own build:

- Linux: newer Rust's suspicious_runtime_symbol_definitions lint wants
  malloc, calloc, realloc and free declared with *mut c_void, the
  signatures std itself relies on. Callers cast.
- Windows: UCRT's snprintf and fprintf are inline functions in its
  headers, so the unit-test binary did not link. The extern block now
  names legacy_stdio_definitions, which compiled programs already link.

Also notes, in build.rs and rt/Cargo.toml, that the runtime's edition is
stated in both and must change together (review feedback).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	rt/src/file.rs
#	rt/src/sys.rs
…LD windows

_rt_field_next tested `off + w > reclen`. A width near 2^63 wrapped the
sum negative, passed, and handed out a window of length ~2^63 at the
buffer, with the next field's offset far below it: LSET through either
wrote out of bounds. It now compares w against the room left. The
assembly runtime had the same flaw.

A FIELD variable is a non-owned window onto its number's record buffer,
and outlives the OPEN that made it. Re-OPENing the number (sequential or
RANDOM) freed the buffer, as the assembly runtime also did, so a later
LSET wrote freed memory and PRINT read it -- glibc aborted the unit test
with a corrupted tcache. Each number's buffer is now MAX_RECLEN bytes,
allocated on its first OPEN FOR RANDOM and never freed or moved; later
OPENs FOR RANDOM reuse it, and the number's mode alone says whether it
is a random file. At most 15 such buffers exist.

LOCK's range and GET/PUT's next record now wrap explicitly, as the
assembly's imul and inc did, rather than relying on release overflow
semantics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every program now links rt/ -- one no_std Rust source over the C library
-- instead of the two hand-written x86-64 assembly trees, which are gone
(src/runtime/, src/rtbuild.rs: about 11,400 lines).

- build.rs builds only rt/; building the compiler no longer needs an
  assembler (GNU as, or clang on Windows).
- Codegen calls runtime helpers with the System V convention on x86-64,
  Windows included (RT_CALL_CONV), as export! defines them: a string
  result comes back in rax:rdx, as Cranelift returns two values, where the
  Windows C convention would use a hidden pointer. Elsewhere both use the
  platform's C convention.
- Every fresh helper is now fallible: alloc_str raises Out of memory, a
  trappable error, where the asm never checked.
- The asm-structure tests (tests/runtime, the fall-through test, the
  assembles-both test) are gone. rtsig's tests now read a call graph from
  rt/src: fallible must be exactly "reaches raise_with_code" and fresh
  exactly "reaches alloc_str", for every helper. A unit test checks rt.o
  needs nothing but the C library and the program's _data_* symbols.

Checked against the assembly runtime (78cfddc's build): 646 of 649
examples and corpus programs give identical stdout, stderr and exit
codes; the 3 others use RND seeded by TIMER, as always. Valgrind: 649
programs, 0 errors.

Deliberate differences: a string's NUL bytes are written (glibc
fprintf %.*s stopped at one); Out of memory is raised; Windows stdout is
now CRT-buffered (flushed before input, stderr and exit).

Measured, ASM -> Rust runtime (best of 3; text+data+bss):
  strings, 3M iterations   0.35s -> 0.35s   4118+1650+9768 -> 7255+2312+112
  PRINT numbers, 2M lines  0.71s -> 0.78s   3416+1626+9720 -> 5805+2224+56
  file write+INPUT #, 500k 0.24s -> 0.20s   6350+1706+9752 -> 12624+2320+5216
  PRINT "hi" executable    21680 -> 17696 bytes on disk

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
End-to-end versions of the two record-buffer fixes, now that programs
link the Rust runtime. A FIELD variable used after its number is closed
and reopened wrote freed memory (valgrind: invalid writes) before
d51c794; the width test pins FIELD overflow at the largest LONG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the runtime in portable Rust, the compiler itself was all that tied
programs to x86-64.

- Cranelift's arm64 backend is built in beside x86; the compiler targets
  its host, as before. has_sse41 is set only on x86-64 (an aarch64 ISA
  rejects it, so every compile failed).
- macOS links with cc ... -Wl,-dead_strip: executables are always PIE
  there, which colocated references already are (adrp+add, bl through
  ld64's stubs); no -no-pie, no --gc-sections, no -lm.
- CI gains ubuntu-24.04-arm and macos-latest jobs, the same steps as Linux.
- The two ulimit -v memory tests run on Linux only; macOS ignores the
  limit. The runtime's nm test likewise, as a module.
- Docs: README platforms, CLAUDE.md (targets, how to check aarch64 under
  qemu), the CLI's description; DEF SEG and VARSEG no longer say x86-64.

Checked on aarch64 Linux under qemu-user (aarch64-linux-gnu-gcc to link):
cargo test passes in full (126 + 589 + 57), and the aarch64 compiler's
programs match the x86-64 compiler's on 646 of 649 examples and corpus
programs, the 3 others being RND/TIMER as always. macOS is checked by
clippy only here, and by its CI job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The file and PRINT USING unit tests made /tmp/xbrt-file-<pid> and
/tmp/xbrt-using-<pid> and never removed them, one more per test run. They
now use tempfile directories, dropped at the end of each test; a test run
in a child process, which may exit on a fatal error without unwinding,
uses a directory its parent made and removes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windows CI died inside the runtime's unit-test binary: a fatal error
("?Permission denied in 30") exited the whole process, naming no test.
Running the tests under Wine (windows-gnu) showed the rest:

- A test that expects a fatal error compared the child's stderr with LF;
  Windows writes CRLF, and the child's stderr, not yet in binary mode as a
  program's is, made it CR CR LF. expect_fatal now normalizes line ends.
- sequential_round_trip expected an LF from PRINT #'s newline, and a LOF
  of 51; both now follow sys::NEWLINE.
- Under cfg(test), a fatal error outside a child process now panics with
  its message, so the harness names the test. Whatever raised Permission
  denied on Windows will say where on the next run.
- The error test clears _err_line, which it left at 30 for every later
  test's report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It returned early, before removing the directory, on a host without nm;
a tempfile guard removes it on every path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- scan_number kept at most 120 characters, so VAL of a 130-digit number
  lost its magnitude. Significant digits now go to strtod as an integer
  and an exponent: leading zeros take no room, digits past 800 only move
  the point (with one sticky digit, so rounding is still exact), and the
  exponent is parsed and saturated here. consumed is unchanged; ordinary
  numbers round exactly as before.
- PRINT USING put a negative number's sign after the $ of a $$ or **$
  field ($-12.50); it goes before, as GW-BASIC writes -$12.50.
- A RANDOM file that was CLOSEd kept its mode, so FIELD carved the old
  buffer and LOC reported the old record. random_buf now needs the
  stream open: FIELD on a closed number is Bad file number (52).
- OPEN FOR RANDOM allocates the record buffer before opening the file,
  so Out of memory no longer leaves #n open and unusable.
- x86-64 macOS links fstat$INODE64, whose struct stat is_directory reads.
- strftime_now uses string::strdup; error.rs shares code_of.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bers without snprintf

Number printing had become 7-12% slower than the asm runtime: every
digit went through a non-inlined NumText::push, fmt_basic copied the
text again to prepend its blank, and print_number a third time to
append PRINT's.

- fmt_double writes through a local cursor, runs of digits and zeros
  as one slice copy each (per-byte loops unrolled into kilobytes)
- NumText is buf[start..end]; fmt_double leaves the byte before the
  text free, so fmt_basic only stores the blank, and print_number
  appends its blank in place (with_trailing_blank)
- A whole number below 10^P (P = 7 SINGLE, 16 DOUBLE) prints exactly
  its integer's digits, so it is converted directly, skipping
  snprintf("%.*e"). Integer variables reach PRINT and STR$ as doubles,
  so this is their path too. A test checks it against the snprintf
  path near zero and both limits, plus explicit boundary strings
  (-0, 1E15, 1E16, 9999999/1E7 SINGLE, .5, 16777216 SINGLE)

Best of 5, 2M iterations, output to /dev/null, output byte-identical:

                          master(asm)  before  after
  PRINT I&/3                 0.33       0.38   0.24
  A$ = STR$(I&/3)            0.32       0.37   0.24
  PRINT I&; I&*1.5#; I&/3    0.94       1.05   0.46
  PRINT I&                   0.30       0.30   0.03
  PRINT X! (SINGLE)          0.25       0.25   0.22
  PRINT (I&+.5#)/3 (never whole)
                             0.34       0.38   0.34

rt.o text 30139 -> 30098; a PRINT I& program's text 5569 -> 6028.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scan_number kept 800 significant digits and stood for any nonzero digit
past them with one sticky 1, as the 801st. UCRT's strtod reads only 768
significant digits, so on Windows it never saw the sticky digit: a number
a hair above a halfway point between two DOUBLEs (2^53+1 followed by a
nonzero digit 1000 places on) rounded to even, down, and the rt test
scan_reads_a_number_of_any_length failed there.

767 digits are still exact -- no DOUBLE, nor any halfway point, has more
than 767 significant digits -- and the sticky digit is now the 768th.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jgarzik
jgarzik merged commit 705d7e0 into master Oct 10, 2026
8 checks passed
@jgarzik
jgarzik deleted the updates branch October 10, 2026 20:38
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.

2 participants