Audience: contributors, AI agents · Tier: reference
This document records the current behavior contract for the src/core modules.
It is intentionally descriptive, not aspirational. Its purpose is to separate:
- behavior we actively preserve
- behavior that belongs only to CLI/file wrappers
- behavior exposed by pure in-memory APIs
- behavior that is still under oracle/parity research
Preserve: current LCC.js behavior that should remain stable during refactorsWrapper-only: behavior that belongs to CLI/file-orchestration paths, not pure APIsPure API: behavior exposed by in-memory reusable methodsResearch: behavior that is still ambiguous, intentionally custom, or not yet fully reconciled with original LCC
Preserve: directassembler.jsaccepts.a,.bin, and.hexPreserve: directassembler.jsrejects non-.atextual source withUnsupported file typePreserve: directassembler.jsrejects.apwith theassemblerPlus.jsguidance messagePreserve: duplicate labels are errorsPreserve: undefined labels are errorsPreserve: label names are case-sensitivePreserve: comments-only and whitespace-only source files do not crashPreserve: line length over 300 raw characters is an error in current LCC.jsPreserve:.orgis implemented for forward gaps by padding with zero wordsPreserve: backward.orgis rejected withBackward address on .orgPreserve: invalid non-numeric.orgoperands are rejected withInvalid number for .org directivePreserve: repeated forward.orgdirectives within range are allowedResearch: original LCC behavior for the 300-character limitResearch: whether original LCC has a true label-length limit distinct from the per-line limitResearch: whether.origshould be treated as a synonym for.orgResearch: whether LCC.js should match oracle’s 1-byteoartifact on certain.orgfailures
Tokenizer splits on whitespace and commas; +/- are NOT delimiters.
| Form | Accepted | Notes |
|---|---|---|
.word N |
✓ | integer literal |
.word label |
✓ | resolves to label address |
.word label+N |
✓ | one token; parseLabelWithOffset extracts offset |
.word label + N |
✓ | three tokens; joined to label+N before evaluation |
.word + |
✗ | caught before pass 2: "Missing operand" |
.word label +N |
⚠ | two tokens; +N is silently ignored; only label resolved |
Preserve:.word N,.word label,.word label+N,.word label + Nare acceptedResearch:.word label +N(spaced unary) silently ignores the+Nsuffix — not guarded
Supported escape set is identical to the oracle's; the divergence is in how
unknown escapes are handled. Full evidence: docs/research/string-escape-parity.md.
| Escape | Accepted | Byte | Notes |
|---|---|---|---|
\n \t \r \\ \" |
✓ | 0a 09 0d 5c 22 |
byte-for-byte identical to oracle |
\0 \a \b \f \v \' |
✗ | — | Unknown escape sequence: \X (oracle: silently drops the \, keeps the literal char) |
\x41 (hex), \101 (octal) |
✗ | — | neither toolchain honors C numeric escapes; oracle emits x41/101 literally |
Preserve: the\n \t \r \\ \"set (matches oracle; shipped demos rely on\n). The #157 headline (".stringrejects\n") is non-reproducible.By design: lccjs rejects unknown escapes with a clear diagnostic where the oracle silently drops the backslash — lccjs stricter-is-safer (seedocs/parity_deviations.mdBY DESIGN). The error isUnknown escape sequence, neverMissing terminating quote.
The tokenizer splits on whitespace and commas; + and - are not delimiters.
| Form | Accepted | Notes |
|---|---|---|
ret |
✓ | offset defaults to 0 |
ret 3 |
✓ | offset = 3 |
ret +3 |
✓ | +3 parsed as integer +3 by parseInt |
ret -3 |
✓ | -3 parsed as integer -3 |
ret+3 |
✗ | ret+3 is one token → unknown mnemonic |
ret+ 3 |
✗ | ret+ is the mnemonic token → unknown |
ret + 3 |
✗ | + becomes operands[0] → Bad number |
Preserve:ret N,ret +N,ret -Nare the accepted offset forms (N in [-32..31])Preserve:ret+N(no space) is rejected as an unknown mnemonic, not a spacing variant
Preserve:evaluateOperandcurrently accepts any syntactic form (num, char, label, star) for any mnemonic — no per-instruction type restrictions are enforced at the assembler levelPure API:determineOperandType(operand)classifies a raw operand token as'num','char','label', or'star'without evaluating it; this is the foundation for future per-mnemonic schemasResearch: which operand type mismatches the oracle actually rejects (e.g., char literal as branch target, raw number as the only operand to a branch) — required before per-mnemonic schemas can be safely enforced without oracle parity risk
Preserve:.global/.externcan cause.ooutputPreserve: object-module flow writes.o,.lst, and.bstPure API:assembleSource(...)returns structured output metadata, words, bytes, and optional reportsPure API:toOutputBuffer()returns serialized output bytes without writing filesPure API:buildReportArtifacts(...)returns report strings without writing files
Preserve: pure assembly paths throw typedAssemblerErrorfailuresWrapper-only: CLI argument validation and usage outputWrapper-only: source file readsWrapper-only: output file writesWrapper-only:name.nnnlookup/creation when report artifacts are written
Preserve: directinterpreter.jsonly accepts.einputsPreserve: invalid.esignature is rejected before.lst/.bstpaths are printedPreserve: division by zero reportsFloating point exceptionPreserve: unsupported trap vectors are runtime errorsPreserve: infinite-loop detection is activePreserve:disableInfiniteLoopDetection = falseby default; set totrueto skip themaxStepscap entirely (used by InterpreterPlus for long-running.apgame loops)Preserve: pure in-memory execution does not enter debug mode automaticallyPreserve: CLI runtime debugging is TTY-gatedPreserve:debugModeis testable viainterpreter.inputBuffer—readLineFromStdin()reads from the buffer when set, so tests can exercise the symbolic debugger by pre-loading debug commands (e.g.'q\n'to quit immediately)Preserve:bpis no longer treated as a fatal unimplemented trap in the CLI pathPure API:executeBuffer(...)executes without requiring sibling filesPure API:executeBuffer(...)returns structured runtime state and optional reportsPure API:buildReportArtifacts(...)returns report strings without writing files
Preserve: invalid signature is an executable-format errorPreserve: incompleteS,G, andAentries are executable-format errorsPreserve: unknown header entries are executable-format errorsPreserve:loadPointaffects memory placement and PC calculation
Preserve: pure interpreter paths throw typedInvalidExecutableFormatErrororInterpreterRuntimeErrorWrapper-only: CLI option parsingWrapper-only: executable file readsWrapper-only: report file writesWrapper-only:name.nnncreation only when stats are being written
Research: exact oraclebpcontinuation / prompt behavior in non-interactive runsResearch: exact oraclesextsemanticsResearch: full symbolic debugger parity
Preserve:.hexand.binassemble then executePreserve:.eexecutes directlyPreserve: first.oargument routes to linking behaviorPreserve:.aand other textual source routes through assemblyPreserve:.afiles that assemble to.eare then executed automaticallyPreserve:.afiles that assemble to.oare not executed
When lcc foo.a bar.a is invoked with multiple non-.o source files:
Preserve: onlyargs[0](foo.a) is assembled and (if.e) executedResearch: remaining.aargs are silently ignored — OG LCC likely behaves the same (one source → one.e), but this has not been oracle-verifiedResearch: if OG LCC does assemble multiple.afiles to multiple.e/.o, LCC.js would need a multi-file pass loop inlcc.js
Preserve:name.nnnis only needed when reports are actually writtenPreserve: linking object files does not requirename.nnnPreserve:name.nnnresolution is based on the current working directory, matching oracle behaviorWrapper-only: report file naming and report writes
Wrapper-only:lcc.jsremains an orchestration CLI and owns wrapper error/exit mapping
Oracle research (2026-05-26) — both oracle tools write the output to the CWD,
not to the directory of the first .o file:
| Invocation | Oracle default name | LCC.js default name | Matches? |
|---|---|---|---|
lcc foo.o bar.o |
link.e in CWD |
link.e in CWD |
✅ |
linker foo.o bar.o |
linktest.e in CWD |
link.e in CWD |
❌ name differs |
Preserve: when linking vialcc.js, default output islink.ein the CWD — matches oraclelccPreserve: output is always in the CWD, regardless of where the.ofiles are located — matches both oracle tools (issue #3 requested the.odir; that would diverge from oracle)Preserve:-ooverrides the default output name and locationResearch: standalonelinker.jsdefault name islink.e; oracle standalonelinkerbinary defaults tolinktest.e— a name-only parity gap for the standalone path
Preserve: object modules are expected to begin withoPreserve: duplicate global symbols are errorsPreserve: undefined external references are errorsPreserve:-ooverrides the default linker output namePure API:parseObjectModuleBuffer(buffer, filename)parses object-module bytes and throws typedLinkerErrorfailures
Preserve: the main linker flow is still more wrapper-oriented than assembler/interpreterResearch: whether to add more pure seams or keep the linker mostly wrapper-oriented in the short term
The current testing strategy should continue to split coverage as follows:
- unit tests for pure helpers and pure API behavior
- integration tests for wrapper behavior and file artifacts
- oracle/e2e tests for compatibility checks
- research-marked skipped tests for ambiguous behavior