diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..cd682e4
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,14 @@
+version: 2
+updates:
+ - package-ecosystem: "maven"
+ directory: "/"
+ schedule:
+ interval: "monthly"
+ # 项目支持JDK 8,JUnit 6需要Java 17,忽略其大版本升级
+ ignore:
+ - dependency-name: "org.junit.jupiter:*"
+ versions: [">=6"]
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "monthly"
diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
index 91106d3..b998496 100644
--- a/.github/workflows/maven.yml
+++ b/.github/workflows/maven.yml
@@ -4,14 +4,106 @@ on: [push]
jobs:
build:
+ # 只跑 JDK 21:协程依赖虚拟线程(JEP 444),这是本实现的下限。
+ # 旧的多版本矩阵(8/11/17)在引入 release=21 之后已经无意义。
+ runs-on: ubuntu-latest
+ name: Build (JDK 21)
+ steps:
+ - uses: actions/checkout@v7
+ - name: Set up JDK 21
+ uses: actions/setup-java@v6
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'maven'
+ - name: Build with Maven Wrapper
+ run: ./mvnw -B test package
+ conformance:
+ # 一致性测试:拿真实的 lua5.4 当裁判。
+ #
+ # 这是本项目的主闸门——「看起来像 Lua」没有价值,唯一能证明「真的是 Lua」
+ # 的办法是同一份脚本在两边跑出逐字节相同的 stdout。所以这个 job 先从源码
+ # 构建一份参考实现,再把它的路径交给测试。
+ #
+ # 从源码构建而不是 apt install,是因为发行版里的 lua 版本不受控(可能是
+ # 5.1/5.3/5.5),拿错版本当参考会得出错误的「不一致」结论,那比没有参考更糟。
runs-on: ubuntu-latest
+ name: Conformance (against lua5.4)
+ steps:
+ - uses: actions/checkout@v7
+ - name: Set up JDK 21
+ uses: actions/setup-java@v6
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'maven'
+ - name: Build reference lua5.4
+ run: |
+ set -e
+ curl -sL -o /tmp/lua.tar.gz https://www.lua.org/ftp/lua-5.4.7.tar.gz
+ mkdir -p target
+ tar xzf /tmp/lua.tar.gz -C target
+ mv target/lua-5.4.7 target/lua54
+ make -C target/lua54 -j2 linux
+ ./target/lua54/src/lua -v
+ #
+ # 字节码差分的另一半:一个挂在参考实现上的小转储器。
+ # 不用 luac -l 是因为它每个 opcode 打的字段不一样、还夹注释,反解回原始
+ # 字段要抄那个大 switch;这里自己对所有 opcode 统一打印全部字段。
+ # 它必须和参考二进制同目录,测试才知道去哪儿找。
+ - name: Build fkdump (bytecode dumper)
+ run: |
+ set -e
+ #
+ # 必须写成一行。YAML 的块标量(|)不处理反斜杠续行:写 "\\" 换行的话
+ # bash 收到的是两个字符,被当成一个名为 "\" 的输入文件,ld 报
+ # "cannot find \:" —— 这一步挂了,后面整个 conformance job 就静默地
+ # 再也没跑过(连续 8 次 push 全是这个错,一致性闸门形同虚设)。
+ gcc -O2 -I target/lua54/src -o target/lua54/src/fkdump src/test/resources/fkdump.c target/lua54/src/liblua.a -lm
+ test -x target/lua54/src/fkdump
+ #
+ # 编译期差分的语料:上游自带的测试套件。这批脚本是 Lua 作者自己写的、
+ # 专门用来压边界的,比手写几十条用例强得多。目前只编译不运行。
+ - name: Fetch upstream lua test suite
+ run: |
+ set -e
+ curl -sL -o /tmp/tests.tar.gz https://www.lua.org/tests/lua-5.4.7-tests.tar.gz
+ mkdir -p target/lua54-tests
+ tar xzf /tmp/tests.tar.gz -C target/lua54-tests --strip-components=1
+ ls target/lua54-tests/*.lua | wc -l
+ #
+ # 跑全量而不是只挑几个类,并且不跳过覆盖率门禁。
+ #
+ # 只挑 ConformanceTest/CodegenTest/ParserTest 等于把上游套件、差分 fuzz、
+ # 二进制 chunk 兼容这两批闸门留在 CI 之外——它们在 CI 上一次都没跑过,
+ # 绿的只是本地。而覆盖率门禁跟一致性测试绑在一起(没有参考二进制时
+ # 一致性测试整类跳过,门禁跟着跳过),所以 -Djacoco.skip=true 一加,
+ # 覆盖率这条线在 CI 上也是空的:两条最硬的闸门都形同虚设。
+ - name: Run conformance tests
+ env:
+ LUA54_BIN: target/lua54/src/lua
+ FKDUMP_BIN: target/lua54/src/fkdump
+ run: ./mvnw -B test
+ quality:
+ # spotbugs静态分析报告(报告模式,不阻断)
+ runs-on: ubuntu-latest
+ name: Static analysis
steps:
- - uses: actions/checkout@v1
- - name: Set up JDK 1.8
- uses: actions/setup-java@v1
+ - uses: actions/checkout@v7
+ - name: Set up JDK 21
+ uses: actions/setup-java@v6
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'maven'
+ - name: SpotBugs
+ run: ./mvnw -B verify -Pquality -DskipTests
+ - name: Upload SpotBugs report
+ if: always()
+ uses: actions/upload-artifact@v7
with:
- java-version: 1.8
- - name: Build with Maven
- run: mvn -B package --file pom.xml
+ name: spotbugs-report
+ path: target/spotbugsXml.xml
+ if-no-files-found: ignore
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..e74dab3
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,99 @@
+name: Publish to Maven Central
+
+on:
+ push:
+ paths:
+ - 'pom.xml'
+ branches:
+ - master
+ workflow_dispatch:
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 2
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v6
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: 'maven'
+
+ - name: Check version change
+ id: check_version
+ run: |
+ CUR_VER=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
+ echo "Current version: $CUR_VER"
+ echo "current_version=$CUR_VER" >> $GITHUB_OUTPUT
+
+ # If manually triggered or HEAD^ does not exist, proceed
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ echo "Triggered manually. Proceeding to publish."
+ echo "should_publish=true" >> $GITHUB_OUTPUT
+ exit 0
+ fi
+
+ git checkout HEAD^ pom.xml || true
+ PREV_VER=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout || echo "")
+ git checkout HEAD pom.xml || true
+ echo "Previous version: $PREV_VER"
+
+ if [ "$CUR_VER" != "$PREV_VER" ]; then
+ echo "Version changed from '$PREV_VER' to '$CUR_VER'. Proceeding to publish."
+ echo "should_publish=true" >> $GITHUB_OUTPUT
+ else
+ echo "Version has not changed ($CUR_VER). Skipping publish."
+ echo "should_publish=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Import GPG key
+ if: steps.check_version.outputs.should_publish == 'true'
+ env:
+ GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
+ run: |
+ if [ -z "$GPG_PRIVATE_KEY" ]; then
+ echo "GPG_PRIVATE_KEY secret is not set. Please configure it in repository secrets."
+ exit 1
+ fi
+ mkdir -p ~/.gnupg
+ chmod 700 ~/.gnupg
+ echo "allow-loopback-pinentry" > ~/.gnupg/gpg-agent.conf
+ echo "pinentry-mode loopback" > ~/.gnupg/gpg.conf
+ gpgconf --kill gpg-agent || true
+ gpg-agent --daemon || true
+ echo "$GPG_PRIVATE_KEY" | gpg --batch --import --pinentry-mode loopback
+
+ - name: Configure Maven settings
+ if: steps.check_version.outputs.should_publish == 'true'
+ env:
+ MAVEN_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
+ MAVEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN }}
+ run: |
+ mkdir -p ~/.m2
+ cat << 'EOF' > ~/.m2/settings.xml
+
+
+
+ central
+ ${env.MAVEN_USERNAME}
+ ${env.MAVEN_PASSWORD}
+
+
+
+ EOF
+
+ - name: Publish to Central Repository via Sonatype Portal
+ if: steps.check_version.outputs.should_publish == 'true'
+ env:
+ MAVEN_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
+ MAVEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN }}
+ run: |
+ ./mvnw -B clean deploy -Prelease \
+ -DskipTests \
+ -Dgpg.passphrase=""
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ad96c2d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,22 @@
+target/
+*.class
+*.jar
+*.log
+
+# Jazzer 模糊测试产物:语料库与 crash 复现文件由本地 fuzzing 生成,
+# 只有经过确认并落到 JazzerFuzzTestInputs 的输入才应当入库
+.cifuzz-corpus/
+crash-*
+
+.idea/
+*.iml
+.project
+.classpath
+.settings/
+
+
+# WorkBuddy 工作空间记忆(本地)
+.workbuddy/
+
+# 上游 code.lua / luac.lua 跑出来的字节码产物
+luac.out
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..7c6b218
--- /dev/null
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1c02e4e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,3058 @@
+# fakescript-java
+
+[](https://github.com/esrrhs/fakescript_java)
+[](https://github.com/esrrhs/fakescript_java)
+[](https://central.sonatype.com/artifact/com.github.esrrhs/fakescript-java)
+[](https://github.com/esrrhs/fakescript_java/actions)
+
+**Lua 5.4 in pure Java — no GC of its own; the JVM does the collecting.**
+
+[简体中文](./README_CN.md)
+
+---
+
+## Design Goal
+
+> Run existing Lua projects on the JVM **without changing a single line of their Lua code.**
+
+This project is a **module-by-module port of the Lua 5.4 reference implementation**
+(`lua-5.4.x` C sources) to Java. It is not a Lua-inspired language that merely looks
+similar — it is Lua 5.4, with the memory-management layer removed and handed to the
+host runtime.
+
+| | |
+|---|---|
+| **Target** | Full Lua 5.4 language semantics + complete standard library |
+| **Method** | Direct, module-by-module translation of the C reference sources |
+| **GC** | **Not implemented.** Object lifetime is entirely the JVM's job (G1/ZGC are concurrent collectors already) |
+| **Runtime** | Java 17+ (coroutines are plain stacks, no threads — see below) |
+| **Compatibility** | Pure-Lua projects; C extension modules (`.so` / `luaopen_*`) are out of scope for now |
+
+### Why there is no GC
+
+`lgc.c` is one of the most intricate parts of the C implementation: tri-colour
+incremental marking, generational mode, write barriers, finalizers, weak tables.
+All of it exists to do what the JVM already does — concurrently, and better tuned
+than a hand-rolled collector written in Java would be. The file is deleted
+outright. Only the *semantics that depend on collection happening* are rebuilt on
+JVM primitives:
+
+| Lua concept | C mechanism | This implementation |
+|---|---|---|
+| Object lifetime | `lgc.c` tri-colour marking | **JVM GC** (G1 / ZGC) |
+| Weak tables (`__mode = "k"/"v"/"kv"`) | weak links swept in `lgc.c` | `WeakReference` + `ReferenceQueue` |
+| `__gc` finalizers | `luaC_checkfinalizer` / `GCTM` | `java.lang.ref.Cleaner` |
+| Short-string interning | `lstring.c` table pinned against collection | weak intern pool; entries die when unreferenced |
+
+Everything else — lexer, parser, code generator, VM, metamethods, coroutines,
+standard library — is a direct port.
+
+---
+
+## Architecture
+
+The package layout mirrors the C sources one-to-one, so a reader holding
+`lua-5.4.x` side by side can find their way around:
+
+| Lua 5.4 C source | Java |
+|---|---|
+| `lobject.h` / `lobject.c` | `LuaValue`, `LuaString`, `LuaTable`, `Proto`, `Closure`, `Upvalue` |
+| `lstring.c` | `StringPool` |
+| `ltable.c` | `LuaTable` |
+| `lfunc.c` | `Proto`, `Closure`, `Upvalue` |
+| `lstate.c` | `LuaState`, `CallInfo`, `GlobalState` |
+| `lzio.c` | `Zio` |
+| `llex.c` | `Lexer` |
+| `lparser.c` | `Parser` (hand-written recursive descent, as upstream) |
+| `lcode.c` / `lopcodes.h` | `FuncState`, `Codegen`, `OpCode` |
+| `lvm.c` | `Interpreter` |
+| `ldo.c` | call frames, protected calls, error propagation |
+| `ltm.c` | `Meta` (metamethod dispatch) |
+| `ldebug.c` | `Debug`, error messages, stack traces |
+| `lapi.c` / `lauxlib.c` | operations on `LuaState`, `AuxLib` |
+| `lundump.c` / `ldump.c` | bytecode load / dump |
+| `lbaselib.c`, `lcorolib.c`, `loadlib.c`, `lstrlib.c`, `ltablib.c`, `lmathlib.c`, `liolib.c`, `loslib.c`, `lutf8lib.c`, `ldblib.c` | corresponding `lib.*` classes |
+| `lgc.c` | **absent** — see above |
+
+The auxiliary layer (`lauxlib.c` and friends) comes along, because the standard
+library is written against it.
+
+---
+
+## Key Design Decisions
+
+These are the places where translating C to Java is not mechanical, and where a
+wrong call costs correctness that is expensive to recover later.
+
+### 1. Lua strings are byte strings, so Java `String` cannot be used
+
+A Lua string may contain `\0`; `#s` counts **bytes**; `s:byte(i)` returns a byte.
+Java `String` is UTF-16 and rejects lone surrogates. Any real-world Lua project
+that touches binary data, UTF-8 text, or embedded `\0` will break if strings are
+backed by `String`. Lua strings are therefore backed by `byte[]`, and every string
+library function is implemented byte-wise.
+
+### 2. Values keep the integer/float subtype split
+
+Lua 5.4 distinguishes `1` (integer) from `1.0` (float): they compare equal, but
+`math.type` differs, `//` differs from `/`, `1/2` is `0.5` while `1//2` is `0`,
+and coercion to string yields `1` vs `1.0`. Values are a **mutable slot** type
+(a `type` tag plus `long`, `double` and an `Object` payload) mirroring C's `TValue`
+— this avoids boxing and lets the VM write stack slots in place.
+
+### 3. Tables keep the array part / hash part split
+
+Ported from `ltable.c`, including the migration logic that decides whether an
+integer key is dense enough to live in the array part. Integer keys and string keys
+are distinct entries: `t[1]` and `t["1"]` are different, as in Lua.
+
+### 4. Coroutines are separate stacks, not threads
+
+Lua coroutines can yield from arbitrarily deep stacks — inside `pcall`, inside a
+metamethod, inside a `for` iterator. The plan was to give each one a Java virtual
+thread. That turned out to be the wrong call, and phase 5 dropped it.
+
+A Lua coroutine is *cooperative*: exactly one runs at a time, and the suspension
+point is stated explicitly by `coroutine.yield`. C models this with one stack per
+`lua_State` plus `longjmp` out to `lua_resume`. Java has the same two ingredients
+— one stack per `LuaState`, and an exception (`LuaYield`) that unwinds the Java
+call stack. **Nothing needs a thread**, because all the Lua state lives on the
+heap: unwinding the Java frames leaves the stack, the `CallInfo` chain and the
+open upvalues exactly as they were, ready to continue on `resume`.
+
+Virtual threads were also *too permissive*. A C function that calls back into Lua
+(`table.sort`'s comparator, `string.gsub`'s replacement function) cannot yield in
+real Lua — it raises `attempt to yield across a C-call boundary`. On a virtual
+thread it would simply work, and that difference is a bug, not a feature, for a
+project whose goal is "run existing Lua code unchanged."
+
+This also drops the Java floor from 21 to 17, which is what makes the local build
+work today.
+
+The machinery that replaced threads is a faithful port of `ldo.c`:
+`lua_resume` / `unroll` / `finishCcall` / `finishpcallk` / `precover`, plus
+`luaV_finishOp`, which completes the half-executed instruction that a yield
+interrupted. C functions that can be interrupted (`pcall`, `xpcall`) register a
+continuation — the Java equivalent of `lua_KFunction`.
+
+### 5. Errors use Java exceptions, not `setjmp` / `longjmp`
+
+`lua_error` becomes a thrown error carrying the Lua error value. `pcall` / `xpcall`
+catch it, as does the coroutine boundary. Care is needed so that non-string error
+values, error objects carrying `__tostring`, and errors crossing a coroutine
+boundary all behave as upstream does.
+
+### 6. No JFlex / Bison
+
+The Lua reference parser is hand-written recursive descent. Porting it directly
+removes the build-time code-generation step, along with the generated-parser drift
+problem the previous architecture had.
+
+### 7. Metamethods: all of them, including the awkward rules
+
+`__index`, `__newindex`, `__call`, `__concat`, `__len`, `__eq`, `__lt`, `__le`,
+`__unm`, `__add` … `__idiv`, `__close`, `__gc`, `__mode`, `__name`, `__metatable`,
+`__pairs`, `__ipairs`. Including the parts that are easy to get wrong: `__le`
+degrading to `not (b < a)` when only `__lt` exists; `__eq` firing only for
+comparable types; `__len` on tables ignoring non-integer results; and `__index`
+chain traversal.
+
+---
+
+## Roadmap
+
+Each phase ends with tests green. Nothing advances on a red phase.
+
+| Phase | Content | Upstream reference |
+|---|---|---|
+| **0** | Foundations: `LuaValue`, `LuaString` + interning, `LuaTable`, `LuaState`, error type, `Zio` | `lobject`, `lstring`, `ltable`, `lstate`, `lzio` |
+| **1** | Lexer + recursive-descent parser; emits directly as upstream does, with no separate tree | `llex`, `lparser` |
+| **2** | Bytecode: full Lua 5.4 instruction set, code generator, constant folding, upvalue resolution | `lopcodes`, `lcode`, `lfunc` |
+| **3** | Interpreter main loop: calls/returns, arithmetic & comparison with coercion, metamethod dispatch | `lvm`, `ldo`, `ltm` |
+| **4** | Closures & open/closed upvalues, `goto`/labels, varargs, multiple assignment and returns | `lparser`, `lvm` |
+| **5** | Coroutines: `coroutine.*`, yield/resume/unroll, yield & error across boundaries | `lcorolib`, `ldo`, `lvm` |
+| **6** | Collection-dependent semantics: weak tables, `__gc`, weak string pool | *(replaces `lgc.c`)* |
+| **7** | Standard library: `base`, `coroutine`, `package`, `string`, `table`, `math`, `io`, `os`, `utf8`, `debug` | `l*lib.c`, `loadlib.c` |
+| **8** | `load` / `loadstring` / `dump`, `require` searchers, `package.path` / `cpath` | `lundump`, `ldump`, `loadlib` |
+| **9** | Conformance: upstream test suite + differential testing against a real `lua5.4` binary | — |
+
+---
+
+## How "it is actually Lua" gets verified
+
+A rewrite that merely *looks* like Lua is worthless. Four gates, in increasing
+order of strength:
+
+1. **Upstream test suite.** The `lua-5.4.x/tests/*.lua` files (`alls.lua`,
+ `big.lua`, `strings.lua`, `coroutine.lua`, `pm.lua`, …) run unmodified. This is
+ the primary gate — upstream's own suite is far more adversarial than anything
+ written by hand here.
+2. **Differential testing.** The same script runs under this implementation and
+ under a real `lua5.4` binary; stdout must match byte for byte. Wired into CI
+ with `lua5.4` installed.
+3. **Differential fuzzing.** Generated programs compared against the reference
+ binary — this is what catches the "no crash but wrong answer" class.
+4. **Coverage gate** (JaCoCo line ratio).
+
+A phase is done only when the gates that apply to it are green.
+
+---
+
+## Status
+
+**Phase 10 — the upstream `lua-5.4.x/tests` suite runs green end to end.** 19
+files whose output is deterministic (`api`, `bitwise`, `calls`, `closure`,
+`code`, `coroutine`, `db`, `errors`, `events`, `gengc`, `goto`, `nextvar`, `pm`,
+`strings`, `tpack`, `utf8`, `vararg`, `verybig`, …) match a real `lua5.4`
+**byte for byte**. Five more (`cstack`, `gc`, `literals`, `locals`,
+`constructs`) print things that vary with the implementation or with a random
+seed, so they are only asserted to run to completion and print `OK` — the reason
+for each is listed under *Phase 10* below. 874 tests green, JaCoCo line coverage
+0.96 against a 0.95 gate. (The upstream-suite gate needs `LUA54_TESTS` or
+`target/lua54-tests`; without a test directory that whole class **skips itself**,
+so a green run on a machine without it is 25 tests weaker than it looks.)
+
+**Phase 9 — complete, including `debug.sethook`.** The previous fakescript
+language implementation has been removed from `master`; see *Migration* below.
+Phases 0 through 9 are in: the compiler produces bytecode that is
+**field-for-field identical** to a real `lua5.4`'s, and the VM executes it —
+**stdout matches a real `lua5.4` byte for byte** across 158 + 121 + 60 + 32 + 25
++ 16 + 21 + 36 differential scripts and 2100 generated programs, and error
+messages match line for line. `string.dump`'s output is additionally compared as
+**raw bytes**, not just as behaviour. All ten standard libraries are in (base,
+coroutine, package, string, utf8, table, math, os, io, debug), `debug` included
+in full — hooks and all.
+
+Phase 6 is the one phase where "no GC of our own" costs real ground. Weak tables,
+`__gc` and the string pool are all rebuilt on JVM reachability, which is *not* the
+same thing as Lua's mark-and-sweep; three places where it cannot be made to match
+are listed below under *Known divergences*.
+
+Landed so far:
+
+| Piece | Upstream | Notes |
+|---|---|---|
+| `Lua` type tags | `lua.h`, `lobject.h` | integer/float subtypes kept as distinct variants |
+| `LuaValue` | `TValue` | mutable slot, so the VM can write stack slots in place |
+| `LuaString` | `TString` | `byte[]`-backed; `\0`, arbitrary and invalid-UTF-8 bytes all preserved |
+| `StringPool` | `lstring.c` `strt` | interned weakly, so unused strings do not pin the heap |
+| `LuaTable` | `ltable.c` | array part + hash part, `rehash`, `#t` borders, integral-float key normalisation |
+| `LuaError` | `lua_error` | carries a Lua value; only this type is caught by `pcall` |
+| `Zio` | `lzio.c` | byte input stream; `EOZ` is `-1`, bytes are unsigned |
+| `LCType` | `lctype.c` | the `luai_ctype_` table copied byte for byte, so `lislalpha` treats `_` as a letter exactly as upstream does |
+| `Numeral` | `lobject.c` `luaO_str2num` | int-first-then-float; strict decimal syntax, so `1d`/`inf`/`nan` are rejected the way C's `strtod` rejects them |
+| `Lexer` | `llex.c` | full token set, all escapes incl. `\z` / `\xXX` / `\u{XXX}` / `\ddd`, long strings & comments, byte-exact error messages |
+| `OpCode` | `lopcodes.h/.c` | all 83 opcodes; order, names and `luaP_opmodes` verified mechanically against upstream headers |
+| `Proto` | `lobject.h`, `lfunc.c` | code / constants / nested protos / upvalues / locals / line info |
+| `Arith` | `lobject.c`, `lvm.c` | the arithmetic used by constant folding — same code path as the VM will use |
+| `ExpDesc`, `FuncState`, `DynData` | `lparser.h` | `expdesc`'s union flattened; block/goto/variable bookkeeping |
+| `Codegen` | `lcode.c` | instruction emission, register allocation, jumps & patching, constant folding, `NEWTABLE`/`SETLIST` |
+| `Parser` | `lparser.c` | hand-written recursive descent, emitting code as it parses |
+| `UpVal` | `UpVal` | two-state (open = stack slot reference, closed = private copy) |
+| `LClosure` | `LClosure` | proto + upvalue array |
+| `CallInfo` | `CallInfo` | frame record; `CIST_FRESH` decides where a Lua→Lua return stops |
+| `LuaState` | `ldo.c`, `lstate.c` | stack, call/return, `pcall`, upvalue open/close, all `luaG_*` errors |
+| `LuaTm` | `ltm.c` | metamethod lookup & dispatch, `__le` fallback, `to-be-closed` |
+| `Vm` | `lvm.c` | the interpreter main loop; all 83 opcodes |
+| `BaseLib`, `MathLib` | `lbaselib.c`, `lmathlib.c`, part of `lstrlib.c` | minimal but differentially-verified |
+| `Diff` (test) | — | shared differential driver: runs one script on both sides |
+| `ConformanceTest`, `CodegenTest`, `ParserTest`, `RuntimeTest` | — | run a real `lua5.4` binary as the oracle |
+| `ClosureTest` (test) | — | Phase 4 gate: closures, `goto`, varargs, multiple assignment |
+| `CClosure` | `CClosure` | a C function bound to values — needed by `coroutine.wrap` |
+| `LuaThread` value (`Lua.VTHREAD`) | `LUA_TTHREAD` | a `LuaState` reachable as a Lua value |
+| `CoroutineLib` | `lcorolib.c` | `create` / `resume` / `yield` / `status` / `wrap` / `isyieldable` / `running` / `close` |
+| `resume` / `unroll` / `finishCcall` / `finishpcallk` / `precover` | `ldo.c` | the yield/resume state machine, no threads |
+| `LuaKFunction` | `lua_KFunction` | continuation for a C function interrupted by a yield |
+| `LuaYield` | `luaD_throw(L, LUA_YIELD)` | unwinding exception; deliberately *not* caught by `pcall` |
+| `Vm.finishOp` | `luaV_finishOp` | completes the half-executed instruction a yield interrupted |
+| `CoroutineTest` (test) | — | Phase 5 gate: 60 differential scripts, incl. yield inside every metamethod |
+| `FuzzTest` (test) | — | 1500 seeded programs + 600 more run inside a coroutine with random yields |
+| `LuaTable.WeakRef` | `iscleared` in `lgc.c` | a weak payload *is* the stored form; keeps the identity hash, since `identityHashCode(null)` is 0 and would collapse every dead key onto bucket 0 |
+| `LuaTable.purge` / `rewrap` | `cleartable` / `traverseweakvalue` | dead entries are dropped at the end of a collection round, and re-wrapped when `__mode` changes |
+| `LuaState.clearDeadStackSlices` | `traversethread`'s atomic branch | nils `[top, stack_last)` on every thread, inside `collectgarbage` |
+| `GcSentinel` | `GCTM` in `lgc.c` | sentinel whose `finalize` resurrects the table onto a pending queue; drained on the Lua thread |
+| `LuaState.close` | `lua_close` → `callallpendingfinalizers` | runs `__gc` for every object that still has one, at exit |
+| `WeakTableTest` (test) | — | Phase 6 gate: 20 differential scripts, split into a collection-dependent half and an always-true half |
+| `GcTest` (test) | — | Phase 6 gate: 12 scripts — once-only, errors swallowed, late `__gc` ignored |
+| `LuaUserdata` | `Udata` | payload + **per-object** metatable; `luaL_testudata` needs the latter |
+| `Undump` | `lundump.c` | reads a precompiled chunk; header check, varint sizes, the `binary string` chunk-name rule |
+| `Dump` | `ldump.c` | writes one; `string.dump`, `strip` |
+| `LoadTest` (test) | — | Phase 8 gate: 25 scripts — modes, chunk names, `_ENV`, reader functions, `loadfile` / `dofile` |
+| `DumpTest` (test) | — | Phase 8 gate: 16 — runtime stdout **and** `string.dump` output byte for byte |
+| `StringPack` | part of `lstrlib.c` | `string.pack` / `unpack` / `packsize`; the only platform-dependent corner of the library |
+| `PackTest` (test) | — | 21 scripts — integers, floats, strings, alignment, wide integers, errors, key set |
+| `DebugLib` | `ldblib.c` + part of `ldebug.c` / `ldo.c` / `lauxlib.c` | `getinfo` / locals / upvalues / `traceback` / registry / metatables / user values / `setcstacklimit` / `sethook` / `gethook` |
+| `LuaHook` | `lua.h` `lua_Hook` | the hook callback; `DebugLib`'s `hookf` is the one `sethook` installs |
+| `LuaLibs` | `linit.c` | `luaL_openlibs`; the ten libraries in `loadedlibs` order |
+| `DebugTest` (test) | — | Phase 9 gate: 36 scripts — levels, options, locals, upvalues, `traceback`, deep stacks, and 14 hook scripts |
+| `LuaLibsTest` (test) | — | `openAll` really opens all ten, and they are the same tables the globals see |
+
+### The bytecode gate
+
+The compiler is validated by **differential comparison, not by self-declared
+expectations**. `src/test/resources/fkdump.c` is a small dumper compiled against
+the reference `lua5.4` sources; for every script it prints every instruction with
+every field (`a`, `b`, `c`, `k`, `Bx`, `sBx`, `Ax`, `sB`, `sC`, `sJ`), plus the
+line, the constant table and the upvalue table, recursively for nested functions.
+`CodegenTest` compares that against `ProtoDump`'s rendering of our `Proto`,
+requiring the two to be identical.
+
+Two corpora: ~50 hand-written scripts covering every construct, and the 33 files
+of the **upstream `lua-5.4.7` test suite** (`target/lua54-tests`, fetched in CI),
+compiled but not run. All of them match.
+
+Error messages are gated the same way: `ParserTest` writes broken sources to disk,
+runs them through a real `lua5.4`, and compares the message byte for byte —
+`'end' expected (to close 'if' at line 1)`, `label 'a' already defined on line 1`,
+` at line 1 jumps into the scope of local 'x'`, and so on. The lexer half
+was already gated like this: a table of deliberately-broken sources is compared
+verbatim, including the `near ''` part whose quoting rules differ between
+`` and everything else.
+
+Two decisions in this phase were settled by running the reference implementation
+rather than by reading the manual:
+
+* an integral float used as a key **is normalised to an integer** on write
+ (`t[1.0]` yields `math.type(next(t)) == "integer"` and `#t == 1`);
+* writing a **NaN** key raises `table index is NaN`, while *reading* one silently
+ returns `nil`.
+
+Both are locked by tests that run against real Lua 5.4 in `ConformanceTest`.
+
+### Three things the reference binary settled this phase
+
+Each of these looks like a bug and is not; all three are locked by tests.
+
+* **`>>` is a logical shift.** `intop(>>,x,-y)` casts to `lua_Unsigned` first, so
+ `-8 >> 2` is `4611686018427387902`, not `-2`.
+* **Upstream's constant table contains duplicates.** `ls->h` — the
+ "constant → index" cache — is shared by the whole chunk, while each function
+ numbers its own constants from 0. A nested function overwrites cached indices
+ with its own small ones; back in the outer function the index no longer points
+ at the right constant, so the entry is rejected and a duplicate is appended.
+* **Integral floats get a perturbed cache key.** Lua compares table keys with
+ `==`, so `100000` and `100000.0` are the *same* key; without
+ `luaK_numberK`'s `r + r*2^-52` trick they would evict each other from the cache
+ and a constant would be re-created on every use.
+
+### The runtime gate
+
+`RuntimeTest` is the Phase 3 gate. Every case is a script that runs **twice** —
+once under this implementation, once under a real `lua5.4` — and the two stdout
+streams must be equal. Error cases are gated on the message line, including the
+`(local 'x')` / `(constant '10')` / `(method 'nope')` suffixes.
+
+The stack is modelled with one invariant that everything else depends on:
+
+> A stack slot `LuaValue` is **never replaced**. `LuaState` always does
+> `stack[i].set(v)`, never `stack[i] = v`.
+
+That single rule is what makes "an open upvalue holds a reference to the stack
+slot" mean the same thing in Java as "an open upvalue holds a pointer to the
+stack slot" means in C. Break it once and closures silently stop seeing updates.
+
+Running it today:
+
+```java
+LuaState L = new LuaState();
+BaseLib.open(L);
+MathLib.open(L);
+L.run("print('hello from Lua')");
+```
+
+### Six things the reference binary settled this phase
+
+Each of these was guessed wrong from the manual or from intuition first; all six
+are locked by differential tests.
+
+* **Arithmetic does not coerce strings, and `"10" + 1 == 11` does not come from
+ the VM.** `lvm.c`'s arithmetic macros use `tonumberns`/`tointegerns`, which
+ ignore strings. The coercion lives in `lstrlib.c`, which installs `__add` (and
+ friends) on the string metatable — first `lua_stringtonumber` both sides, then
+ `lua_arith`. Skip those metamethods and you get a plausible but wrong
+ `attempt to perform arithmetic on a string value`.
+* **A C function's `n` return values are the top `n` pushed values.** `ipairsaux`
+ pushes `i`, then pushes `t[i]`, and returns `1` when `t[i]` is nil — so the
+ value seen by the generic `for` is that trailing **nil**, not `i`. Written the
+ intuitive way (`return i`), `for _, v in ipairs(t)` never terminates, because
+ the loop only stops when the *first* result is nil.
+* **Every arithmetic opcode is followed by an `OP_MMBIN*`.** On success the VM
+ does `pc++` to skip it; on failure it falls through and the metamethod gets
+ dispatched. Miss one `pc++` and a working expression runs its metamethod too.
+* **`OP_NEWTABLE`'s `EXTRAARG` is unconditional** (because `k == 0` ⟺ `Ax == 0`),
+ while `OP_SETLIST`'s is conditional. Reading it conditionally produces
+ `malformed opcode EXTRAARG` on every table constructor.
+* **C's `%g` strips trailing zeros; Java's does not.** `1.5` printed as
+ `1.5000000000000` until `formatG` was written by hand.
+* **`luaG_runerror` and `luaL_error` take their position differently.** The
+ former uses `isLua(ci)` on the *current* frame; the latter is
+ `luaL_where(L, 1)` and walks back one level to the caller. A library function
+ that uses the former reports no `src:line:` prefix at all, because its own
+ frame is a C frame.
+
+### The fuzzing gate
+
+`FuzzTest` generates 2100 programs from a small grammar and diffs each one
+against the reference — 1500 plain, and 600 more whose main chunk runs inside a
+`coroutine.create` with `coroutine.yield` sprinkled at random points and driven
+by a bounded `resume` loop. Two constraints keep it from becoming a nuisance
+generator:
+
+* **It must terminate.** Only bounded `for` / `ipairs` loops, no `while`, no
+ `repeat`, no `goto`; a call may only target a function that finished being
+ defined *earlier*, so the call graph is a DAG and recursion is impossible.
+* **It must be reproducible.** Seeds increment from 0 — no `Math.random`. A
+ failure is reproducible from "seed 84".
+
+The generator knows nothing about how we *think* Lua behaves, which is the point:
+it is the only gate that finds "does not crash, but the value is wrong".
+
+### Four things the reference binary settled this phase
+
+* **`luaF_newtbcupval` checks `__close` at assignment time, not at scope exit.**
+ `OP_TBC` runs right after the local receives its value, so
+ `local x = 5 print(x)` raises
+ `variable 'x' got a non-closable value` *before* printing anything. `nil` and
+ `false` are exempt (`l_isfalse`), so `local x = nil` is legal.
+* **An error inside `__close` during unwinding *replaces* the original error.**
+ `error('orig')` followed by a `__close` that does `error('in close')` makes
+ `pcall` return `in close`. Keeping the original instead is wrong.
+* **The code generator folds `OPR_MINUS` and `OPR_BNOT` only — never `OPR_LEN`.**
+ So `#7.5` is a *runtime* error, not a load-time one. Folding it would make a
+ script fail to compile that upstream compiles and only fails when run.
+* **Two harness rules, learned the hard way.** When a script errors, the stdout
+ it already produced must still be compared (the reference's stdout and stderr
+ are captured together, so "print ten lines, then crash" has both in its
+ output); and the `lua.c` program-name prefix must be stripped *everywhere*,
+ not just at the start, because it appears mid-output once stdout precedes it.
+
+### Five things the reference binary settled this phase
+
+* **`pcall`, `xpcall`, metamethods and `for` iterators are yieldable; C-library
+ callbacks are not.** `table.sort`'s comparator and `string.gsub`'s replacement
+ function raise `attempt to yield across a C-call boundary`, because
+ `ltablib.c`/`lstrlib.c` call back through `lua_call` (`luaD_callnoyield`).
+ `__tostring` invoked by `print` is the same story. This is precisely the
+ behaviour virtual threads would have "fixed" — and broken.
+* **The main thread is never yieldable.** `lstate.c`'s `lua_newstate` does
+ `incnny(L)` unconditionally, so `coroutine.isyieldable()` is `false` on the main
+ thread even with no C call in sight. A coroutine starts at zero, and every
+ `resume` resets it to zero (`lua_resume` copies only the low 16 bits of
+ `nCcalls` from the resumer).
+* **`resume`'s arguments *are* the return values of `yield`.** `lua_resume`'s
+ `poscall(ci, nargs)` is what makes `coroutine.yield(1)` see `99` after
+ `resume(co, 99)` — the number of values moved back is the number passed in,
+ not the number yielded out.
+* **`coroutine.wrap` reports its own name, not `create`'s.** `luaB_cowrap` calls
+ `luaB_cocreate` directly, and `luaL_argerror` derives the name from how the
+ *current* frame was called — so `coroutine.wrap(nil)` says
+ `bad argument #1 to 'coroutine.wrap'`.
+* **`luaV_finishOp` has a branch per interruptible opcode, and `OP_CONCAT`'s is
+ not optional.** Concatenation proceeds in batches, so after a `__concat` yield
+ the metamethod result has to be moved back one slot, `top` pulled in by one,
+ and the remaining elements concatenated — possibly yielding again. Skipping it
+ silently yields the *operand table* instead of the concatenation result.
+
+### Five things the reference binary settled in Phase 6
+
+* **The GC-visible stack ends at `L->top`, and that is load-bearing.**
+ `lgc.c: traversethread` marks only `[stack, top)` and then, in the atomic
+ phase, nils out `[top, stack_last)` — the source comment calls it "clear dead
+ stack slice". So a value sitting in a register *above* `top` is invisible to
+ the collector, and gets collected. Two shapes depend on this: a value pushed
+ by a C function such as `next` (the call returns, `top` drops back, the slot
+ still holds the key), and the table built by the last iteration of a loop
+ (`for i = 1, 5 do t[i] = {i} end` leaves `{5}` in a register while `L->top`
+ only reaches the call's arguments). Our `stack` is a Java array, so **every
+ slot is a live reference** — `LuaState.clearDeadStackSlices` reproduces that
+ clearance, and has to run *inside* `collectgarbage`, not on every `top`
+ change (see the next bullet).
+* **…but clearing eagerly on every `top` change breaks two things.** C only
+ clears during the atomic phase. Doing it inline silently destroyed both
+ `luaT_callTMres`'s result (the destination register can *be* the slot being
+ popped) and the error object of `pcall` (`LuaError.value` aliases a live stack
+ slot, because `error('boom', 0)` throws `L.arg(0)` itself — so `pcall` started
+ returning `false nil`). Waiting until `collectgarbage` is both faithful and
+ exactly as precise.
+* **A weak table's storage has to *be* weak, not merely carry a `WeakReference`
+ beside it.** The payload in the slot is the `WeakReference`; a strong payload
+ means nothing is ever released. That has a consequence: `LuaTable.get` must
+ hand back a *copy* for weak tables, so the write fast path
+ (`OP_SETTABLE`'s "slot found, write into it") cannot be used on them —
+ writing into a copy is a no-op, and `t.f = g` would leave `t.f` equal to `f`.
+* **`__mode` is read dynamically, and re-wraps everything that is already in the
+ table.** Building a table, filling it, and *then* calling
+ `setmetatable(t, {__mode = 'k'})` still lets those entries be collected —
+ measured. Since our weakness is a storage form, changing the mode has to walk
+ the table and re-wrap in place, and a weak table must keep everything in the
+ hash part (array slots are plain `LuaValue`s and cannot hold a `WeakRef`).
+* **`__gc` needs the object *alive*, which no JVM reference type can give you.**
+ Every "it's dead" notification (`WeakReference` cleared, `Cleaner`,
+ `PhantomReference`) arrives after the object is unreachable, and the reference
+ cannot be recovered. Only `finalize()` runs while `this` is still usable, and
+ it is allowed to *resurrect*. So a table with `__gc` gets a sentinel that only
+ it points to; when both go unreachable the sentinel's `finalize` pushes the
+ table onto a pending queue, and the next `collectgarbage` drains that queue on
+ the **Lua thread** — never on the JVM finalizer thread, where calling into a
+ `LuaState` would race and any exception would vanish without a trace.
+
+### Three known divergences from the reference
+
+These are not oversights; each is a place where the JVM has no primitive to
+match, and each is documented rather than papered over.
+
+* **Ephemeron tables only handle the case where the value *is* the key.**
+ `__mode='k'` with `t[k] = k` clears the entry (matching the reference), and
+ the usual cache shape `cache[obj] = {fresh data}` keeps its value (also
+ matching). But `t[k] = {k}` — value *contains* key, key otherwise
+ unreachable — keeps the entry where Lua would drop it. The two halves are
+ mutually exclusive in Java: holding the value strongly keeps the key alive;
+ holding it weakly loses the value whenever the key is alive but the value is
+ not otherwise referenced, which is exactly the common cache. Data loss is
+ worse than retention, so we retain.
+* **A weak entry whose key has a `__gc` is dropped one cycle early.** Lua marks
+ finalizable objects, so the entry survives the cycle that schedules the
+ finalizer. Ours keys off `WeakReference` clearing, which happens in the same
+ cycle.
+* **Objects with `__gc` are only reclaimed when `collectgarbage` runs.** The
+ JVM never tells us an object is unreachable except by collecting it, so there
+ is no way to schedule a finalizer without an explicit cycle.
+
+### Phase 7 so far
+
+`string` (`StringLib`), `table` (`TableLib`), `math` (`MathLib`), `utf8`
+(`Utf8Lib`), `os` (`OsLib`), `io` (`IoLib`) and `package` (`PackageLib`) are
+ported, each with its own differential gate — `StringLibTest` (29),
+`MathLibTest` (26), `TableLibTest` (18), `Utf8LibTest` (13), `OsLibTest` (24),
+`IoLibTest` (35), `PackageLibTest` (22).
+
+#### Ten things the reference binary settled in Phase 7
+
+* **A shared `lauxlib.c` layer has to come *before* any library.** Argument
+ checking (`checkString` / `checkInteger` / `argError` / …) is not scaffolding:
+ the message text is part of the observable contract, and six hand-rolled
+ copies would drift apart within a week. One detail is only right if the
+ parameter numbering is 1-based: `s:rep(nil)` reports `bad argument #1`,
+ because `luaL_argerror` decrements for a method call.
+* **`math.max` / `math.min` use `lua_compare`, not a numeric comparison.** So
+ `math.max("a","b")` is `"b"`, and `math.max(1,"b")` raises
+ `attempt to compare number with string` via the failed `__lt` path. They also
+ preserve the operand's subtype: `math.max(1, 2.0)` is `2.0` (a float).
+* **`math.fmod`'s float path is C's `fmod`, not Lua's `%`.** They differ in the
+ sign of the result: `math.fmod(-7.5, 2)` is `-1.5` (sign of the dividend)
+ while `-7.5 % 2` is `0.5` (sign of the divisor). Reusing the VM's `Arith.modf`
+ here is the easy mistake and it is wrong by a sign.
+* **`math.log`'s base-2 and base-10 branches are genuinely different
+ computations.** C calls `log2` / `log10`; `log(x)/log(2)` differs from
+ `log2(x)` on 9 of 43 sampled inputs, and `log(x)/log(10)` from `log10(x)` on
+ 25 of 43. Java has no `log2`, so it is reconstructed as
+ `e + log(m)/ln2` from the mantissa decomposition `x = m * 2^e`, which lands
+ within 1 ulp on 190 of 192 inputs.
+* **`table.insert` on an object with `__newindex` must call it.** `ltablib.c`
+ does not use `luaH_setint`; it uses `lua_seti`, which goes through
+ `luaV_finishset`. Writing raw leaves `table.insert(ud, v)` silently doing
+ nothing — no error, no write.
+* **`os.execute` must spawn `sh`, not `/bin/sh`.** C's `system()` is
+ `execl("/bin/sh", "sh", "-c", cmd)` — **argv[0] is `sh`** — and the shell
+ prefixes its diagnostics with argv[0]. Spelling the full path turns
+ `sh: 42: command not found` into `/bin/sh: 42: command not found`, which the
+ differential gate sees immediately.
+* **A userdata's metatable lives on the object, not on its type.**
+ `luaL_testudata` asks "is this userdata's metatable the one registered under
+ `FILE*`?", so file handles cannot share a per-type metatable with the
+ internal ones (the PRNG state, `gmatch`'s state) without those being mistaken
+ for files. `LuaUserdata` was added for this, and with it `__gc` — otherwise a
+ file left un-`close()`d by a script never gets closed at all.
+* **`io` on a real file must be block-buffered, and `io.write` must not be.**
+ C stdio buffers file output: after `io.output(p):write("x")`, reading `p`
+ back gives *nothing*, because the bytes are still in the `FILE*`. Standard
+ streams are the opposite — they are line/unbuffered, and `io.write`'s output
+ has to reach whichever stream the host installed. So `IoLib` buffers
+ `RandomAccessFile` writes (flushing on `flush`, `seek`, any read, and close)
+ and writes straight through for stdin/stdout/stderr.
+* **`require`'s searcher result is tested with `lua_isstring`, not "is a
+ string".** A searcher that returns the *number* `42` has its value
+ concatenated into the not-found message; only a non-string, non-number
+ result is treated as "this searcher said nothing" and drops the
+ `\n\t` prefix again.
+* **Load-time errors run the chunk name through `luaO_chunkid` too.** The
+ module file is compiled as `"@" + path`, but `luaG_addinfo` strips the `@`
+ before printing, so a syntax error in a required module reads
+ `/tmp/m.lua:1: syntax error near 'is'` — not `@/tmp/m.lua:1: ...`.
+* **Every library has to be registered in `_LOADED`, not just in `_G`.**
+ `coroutine` was the one that slipped through: `require("coroutine")` reported
+ `module 'coroutine' not found`, because the table was only in the globals.
+
+#### One divergence that is accepted, not fixed
+
+`sin` / `cos` / `tan` / `asin` / `acos` / `atan` / `exp` / `log` / `log10` /
+`sinh` / `cosh` / `tanh` / `pow` are **not bit-identical** to the reference.
+Java's `Math` is fdlibm; the reference binary links the platform libm (macOS
+`libsystem_m` here). Both are correct to within 1 ulp of the true value, but
+they round differently in the last place: of 43 typical inputs, `sin` differs on
+1, `tan` on 12, `cosh` on 6. Closing that gap means shipping a libm of our own,
+which neither fits the goal nor matches what Lua itself guarantees — Lua's own
+results shift with the platform. The differential cases therefore compare these
+functions to 14 significant digits (1 ulp is ~2.2e-16 relative). Everything
+that *can* agree does agree: `sqrt`, `abs`, `floor`, `ceil`, `frexp`, `ldexp`
+and the `log` branches above are bit-exact.
+
+#### Four divergences in `io` that are accepted, not fixed
+
+* **`file (%p)`.** `f_tostring` prints a pointer, so `print(io.stdout)` can
+ never match byte for byte. `IoLib` emits a same-shaped hex value and the
+ differential harness normalises `0x…` away.
+* **`stdin` seeking.** The reference's stdin in the harness is redirected from
+ `/dev/null`, which *is* seekable (`lseek` returns 0); ours is a byte array,
+ which is not. `io.stdout:seek()` agrees (`nil, "Illegal seek", 29` on a
+ pipe); `io.stdin:seek()` is not compared, because the difference is in where
+ the stream comes from, not in what `io` does.
+* **Writing to an update stream straight after reading it.** Where the bytes
+ land is C's read-ahead buffer, and ISO C leaves it undefined. Measured: 13 on
+ a 12-byte file, 4097 on a 6002-byte one, 5001 after `read(5000)`. We write at
+ the logical position. On the well-defined path (with an intervening `seek`)
+ the two agree. See *The `io.open` mode matrix* below.
+* **The position reported after writing to an append file.** The reference gives
+ `base + n` while the data sits in the buffer and `EOF + n` once it is flushed
+ (the boundary is 4096); we always give `EOF + n`, which is the POSIX answer.
+ The bytes written are identical either way. See the same section.
+
+#### Two divergences in `package` that are accepted, not fixed
+
+`loadlib.c`'s fallback branch is the honest one here: there is no `dlopen` on
+the JVM (reaching it would mean JNI or FFI — a different project), so the C
+searcher behaves the way C does on a platform built without dynamic loading.
+
+* **`package.loadlib`** returns `nil, "dynamic libraries not enabled; check
+ your Lua installation", "absent"`. The reference binary is built with
+ `dlopen`, so it returns a `dlerror()` string and `"open"`.
+* **`require` of a module whose `.so` actually exists** stops at "dynamic
+ libraries not enabled" instead of loading it. Modules that only exist as
+ `.lua`, and `package.preload` entries, are unaffected.
+
+### Phase 8 so far
+
+`load` / `loadfile` / `dofile` (`BaseLib`) and the binary-chunk pair
+`lundump.c` / `ldump.c` (`Undump` / `Dump`) are in, with two differential
+gates: `LoadTest` (25) and `DumpTest` (16).
+
+`DumpTest` compares at **two granularities**, and the second one is the real
+gate for this phase:
+
+1. **Runtime.** Each script's stdout is compared byte for byte, as everywhere
+ else.
+2. **Artefact.** `string.dump(f)` is written to a file by each side and the two
+ files are compared byte for byte. Runtime-only comparison would let "the
+ compiler emits something slightly wrong that still happens to run" through;
+ the artefact comparison pins down constant ordering, the line tables, local
+ and upvalue debug info, and `maxstacksize` as well.
+
+Eight shapes are checked that way — plain, nested, vararg, upvalue, `goto`,
+loop, assorted constants, and a constant string longer than
+`LUAI_MAXSHORTLEN` (which takes the non-interned long-string path on both the
+dump and the undump side).
+
+#### Six things the reference binary settled in Phase 8
+
+* **`luaU_undump` rewrites the chunk name before it ever reports an error.**
+ A leading `@` or `=` is dropped; a name that starts with the signature byte
+ (i.e. the chunk name *is* the binary chunk, which is what `load(s)` uses when
+ no second argument is given) becomes the literal `binary string`. So
+ `load("\27Lua")` reports `binary string: bad binary format (truncated
+ chunk)` — not an ESC-prefixed mess.
+* **`f_parser` consumes the first byte on the binary path but hands it back on
+ the text path.** `luaU_undump`'s comment says so directly: "skip 1st char
+ (already read and checked)". Ungetting it before `undump` misaligns the whole
+ header by one byte and turns `truncated chunk` into
+ `not a binary chunk`.
+* **`luaO_chunkid` has a third form, and it is the one `load` hits.** Only
+ `@x` and `=x` are stripped; anything else is wrapped as `[string "x"]`.
+ `load("x++")` reports `[string "x++"]:1: syntax error near '+'` — a
+ "strip the prefix" implementation that skips the wrapping reports `x++:1:`,
+ and every load-time error message is then wrong.
+* **Chunk names are byte strings, and `luaO_chunkid` truncates by bytes.**
+ Decoding to a Java `String` first and truncating there gets the length wrong
+ on non-UTF-8 names. `chunkId` therefore has a `byte[]` core and the `String`
+ overload goes through it.
+* **A prototype with no source gets *no* position at all — not even a line
+ number.** `luaG_addinfo` is `if (src) ... else return msg`. This is
+ observable: `string.dump(f, true)` strips the source, so
+ `pcall(load(string.dump(f, true)))` reports the bare `boom`. Falling back to
+ the chunk name not only adds a prefix, it splices the raw binary chunk into
+ the message (invalid UTF-8, so it decodes to replacement characters).
+* **`lundump`'s `default: lua_assert(0)` is a no-op.** `lua_assert` compiles to
+ `((void)0)` in a default build, so a chunk whose constant type byte is `99`
+ loads fine and that constant is just `nil`. Raising an error there is
+ inventing a semantic of our own. Measured, not assumed: the hand-built chunk
+ loads as a `function` on the reference.
+
+One more, on the dump side: `dumpFunction` compares sources **by reference**
+(`f->source == psource`), not by content. Short strings are interned so the two
+agree, but a long source name is not interned and C writes it out again for
+every nested function. Comparing by content would emit *less* than C does.
+
+### `string.pack` / `string.unpack` / `string.packsize`
+
+Ported as `StringPack` (the pack/unpack half of `lstrlib.c`), with `PackTest`
+(21 scripts). This is the **only part of the standard library whose output
+depends on the host platform**: `l` / `L` are C's `long`, `T` is `size_t`, `!`'s
+default maximum alignment is `offsetof` of `LUAI_MAXALIGN`, and the default byte
+order is the host's. Those are pinned to the platform the reference binary runs
+on — LP64 + little-endian — because on a platform where `long` is 4 bytes the
+reference itself emits different bytes.
+
+Two more things the reference binary settled here:
+
+* **`X` consumes the option after it.** `getdetails` calls `getoption` to learn
+ the next option's alignment, and that call advances the format string. So
+ `string.packsize("Xi4")` is `0`, and `string.pack("Xi4", 7)` is `""`. An
+ implementation that peeks without advancing returns `4` — which looks more
+ sensible and is wrong.
+* **`str_pack`'s `lua_pushnil` marker is observable.** It exists to separate the
+ arguments from the string buffer, but it sits at index 2, so with no second
+ argument `string.pack("c2")` reports `string expected, got nil` rather than
+ `got no value`.
+
+### Phase 9 — the `debug` library, hooks included
+
+`ldblib.c` is in as `DebugLib`, together with the parts of `ldebug.c` that only
+the introspection API needs (`lua_getstack` / `lua_getinfo` / `luaG_findlocal` /
+`luaG_traceexec` / `luaG_tracecall`) and `lauxlib.c`'s `luaL_traceback`.
+`DebugTest` (36 scripts) is the gate. All sixteen `debug` functions are in.
+
+`LuaLibs.openAll` (`linit.c`'s `luaL_openlibs`) also landed — before it existed,
+"open the standard libraries" was ten calls that only the test driver knew the
+order of.
+
+#### The hook machinery, and where its call points are
+
+`sethook` is not a library function that can be ported on its own: it is a hook
+*dispatcher* whose call points are spread over the interpreter. Porting it meant
+adding, in addition to `DebugLib.dHook` (`ldo.c: luaD_hook`):
+
+| Call point | Upstream | Fires |
+|---|---|---|
+| `LuaState.precallC` | `ldo.c` | `call` for C functions — **before** the C function itself runs |
+| `DebugLib.dHookCall` from `Vm`'s `startfunc` and from `OP_VARARGPREP` | `ldo.c: luaD_hookcall` | `call` / `tail call` for Lua functions |
+| `DebugLib.rethook` from `LuaState.poscall` | `ldo.c: rethook` | `return` for both kinds |
+| `DebugLib.traceExec` from `Vm`'s `vmfetch` | `ldebug.c: luaG_traceexec` | `line` and `count` |
+
+plus `trap` on `CallInfo` and `hook` / `hookmask` / `hookcount` / `allowhook` /
+`oldpc` on `LuaState`. The point of `trap` is that it is a *local* variable in
+`luaV_execute`, so with hooks off the main loop does not even branch.
+
+#### Four more things the reference binary settled
+
+* **`lua_sethook` must call `settraps`.** The main loop's `trap` is a local that
+ is only re-read from the frame at a few points (`updatetrap`). Turning a hook
+ on *mid-execution* therefore does nothing at all unless every live Lua frame's
+ `trap` is set to 1 — which is what `settraps` does. Without it,
+ `debug.sethook(f, "l")` produces zero line events.
+* **A hook error is *not* caught by the `pcall` it is reported for.** The `call`
+ hook for `pcall` fires inside `precallC`, *before* `pcall` establishes its
+ recovery point, so `pcall(function() ... end)` cannot catch an error raised by
+ a `call` hook on `pcall` itself.
+* **`CIST_HOOKED` is tested before `isLua`** in `funcnamefromcall`. The frame
+ carrying the mark is often a **C** frame (the `call` hook for `pcall` is
+ reported on `pcall`'s own C frame), so testing `isLua` first never reaches it
+ — and the traceback line changes from `in hook '?'` to
+ `in function `.
+* **Hooks can never yield.** `hookf` calls the user function with `lua_call`,
+ which goes through `luaD_callnoyield`, so the non-yieldable counter is non-zero
+ inside a hook. Measured: `coroutine.yield()` in a hook reports `attempt to
+ yield from outside a coroutine` on the main thread and `attempt to yield
+ across a C-call boundary` in a coroutine — in both cases the program or
+ coroutine dies. This is why the port does not implement `CIST_HOOKYIELD` at
+ all: it is unreachable from Lua. (`CIST_HOOKYIELD` exists upstream for *C*
+ hooks installed via the C API, which are called directly rather than through
+ `lua_call`.)
+
+#### One ordering bug found by the hook gate
+
+`allowhook` must be restored at the **protected-call boundary**, *after* the
+message handler has run — not before. The handler runs at the throw point, where
+`allowhook` is still 0 (that is what stops a traceback from re-entering the
+hook). Restoring it earlier means the traceback triggers the hook again, which
+raises again, which `applyErrMsg` turns into `error in error handling`. The
+symptom: `debug.sethook(function() error("x") end, "c")` followed by an uncaught
+error printed `error in error handling` instead of a real traceback.
+
+#### Five things the reference binary settled in Phase 9
+
+* **Level 0 is the C frame of `getinfo` itself.** `lua_getstack(L, 0, &ar)`
+ succeeds and yields `what = "C", currentline = -1, name = "getinfo"`. The name
+ comes from `funcnamefromcall` reading the *caller's* frame, so it answers "what
+ was the caller calling", not "what is this frame called". Getting this off by
+ one shifts every `debug.getinfo(n)` result by a level.
+* **`'f'` and `'L'` need `lua_rotate`, not a pop.** The result table is pushed
+ *after* `lua_getinfo`, so `lua_getinfo`'s own outputs sit *underneath* it.
+ `treatstackoption` is `lua_rotate(L, -2, 1)` followed by `lua_setfield(L, -2)`.
+ Doing `L.top--` instead pops the result table itself — the symptom is
+ `debug.getinfo(1)` returning something that prints as a function, and
+ `debug.getinfo(1).func` holding the table.
+* **`pushfuncname` tries a *global* name first.** `error` is `global 'error'` as
+ far as `getobjname` is concerned (it is a `GETTABUP _ENV`), but a traceback
+ prints `function 'error'` — `pushglobalfuncname` looks the function up **by
+ value** in `_LOADED._G` and wins. Checking only `ar.namewhat` gets the
+ qualifier wrong on every C function that has a global name.
+* **The message handler runs *before* the stack unwinds** — `luaG_errormsg`
+ calls it at the throw point, not from `luaD_pcall`'s recovery. This is the whole
+ reason `xpcall(f, debug.traceback)` shows the full traceback instead of just the
+ outer two frames. Java has no `longjmp`, but the *Lua* stack (`stack` / `top` /
+ `ci`) is still intact at the `catch`, so running the handler at the top of
+ `pcall`'s catch is equivalent.
+* **`LEVELS1` / `LEVELS2` are 10 and 11**, not a round 10/10, and the head and
+ tail are *not* the same length. The "(skipping N levels)" count is off by one
+ otherwise, and only on stacks deep enough to trigger it.
+
+#### Two bugs found by the `debug` gate, neither of them in `debug`
+
+* **`OP_TESTSET`'s condition was inverted, which broke `and` / `or`.** Upstream
+ tests `l_isfalse(rb) == k`; this port tested `toBoolean() == (k != 0)` — and
+ those two are exact opposites over `k`'s two values. It is a *quiet* bug:
+ `TESTSET` only survives when the result register differs from `B`
+ (`patchTestReg` rewrites it to `OP_TEST` otherwise), so `if x then` was always
+ right while `x and y` was always wrong. The symptom is
+ `print(t and "yes")` printing the table, and — worse — `cond and a or b`,
+ the most common idiom in Lua, taking the wrong branch. **The bytecode gate
+ could not see it**: the emitted code was field-for-field identical to the
+ reference's. `RuntimeTest.logicalOperatorsReturnValues` now pins the returned
+ value, not just the truthiness.
+* **`xpcall`'s handler ran after the call chain had already been restored.**
+ See the fourth bullet above; `xpcall(f, debug.traceback)`'s traceback was two
+ frames long instead of seven.
+
+#### One thing the reference binary settled that is a *driver* difference
+
+`lua.c` runs the script from inside `pmain`, a **C function**, so an extra C
+frame sits under the main chunk. It shows up as the trailing `[C]: in ?` in every
+traceback and as `debug.getinfo(3).what == "C"`. A host that calls
+`luaL_dostring` directly has no such frame and sees neither. This is therefore
+fixed in the differential driver (`Diff` now mirrors `lua.c`: `pmain` +
+`docall` + `msghandler`), not in the engine — bending the engine to match would
+have made `luaL_dostring` wrong instead.
+
+### Phase 10 — the upstream conformance suite, end to end
+
+The heaviest part of the fourth gate: `UpstreamSuiteTest` runs the upstream
+`.lua` files unmodified and compares their output with the reference binary
+**byte for byte**; `UpstreamSmokeTest` collects the ones that must not be
+compared that way but still have to finish.
+
+| File | Why it cannot be compared byte for byte |
+|---|---|
+| `cstack.lua` | each case prints a `final count` = how much C stack one nesting level eats; Java and C necessarily differ |
+| `gc.lua` | the whole file tests the incremental collector's `count` / `step` / `setpause`; this port has no such layer |
+| `literals.lua` | the trailing locale section needs `os.setlocale("pt_BR")` to actually take effect; the JVM cannot |
+| `locals.lua` | `tracegc` writes a progress dot from a finalizer with `io.stderr:write'.'`; when it runs is up to the JVM |
+| `constructs.lua` | line 319 is `_ENV.GLOB1 = math.random(0, 1)`; two processes can never share a seed |
+| `sort.lua` | it prints sort timings and a comparison count, and the input is generated with `math.random` |
+| `math.lua` | it prints `random seeds`, the range of the random draws, and timings |
+
+#### What the reference binary settled in Phase 10
+
+* **`__close` can still yield after an error.** When `yieldable(L)` holds,
+ `lua_pcallk` does **not** install a local recovery point: the error travels all
+ the way to `lua_resume`, and `precover` → `finishpcallk` →
+ `luaF_close(..., yy = 1)` finishes the job — so every `__close` may yield, and
+ a new error raised by a `__close` **replaces** the old one for the variables
+ still pending. In `locals.lua`'s "yielding inside closing metamethods", three
+ `` variables yield in turn; the middle one raises `error(err + 20)`
+ after yielding, so the third sees (and `pcall` finally returns) 30, not 10.
+ This port uses Java exceptions instead of `longjmp`, so the recovery point sits
+ in `pcall`'s `catch` and `yy` has to be passed down explicitly — `pcall` picks
+ it from `yieldable()`, which is exactly `k != NULL && yieldable(L)`.
+* **When a yield interrupts the unwinding, somebody has to keep the error
+ object.** In C it is always on the stack (`luaD_seterrorobj` rewrites it before
+ every close, and `finishpcallk` reads `L->top - 1`). Here it lives in the
+ exception and vanishes with the Java stack, so it is parked in
+ `LuaState.closeErrPending` until the next `finishPcallK`. It must **yield to a
+ newer error**, or the third variable would still see the stale 10.
+* **`luaB_error` has to be written in the C shape**, `lua_settop(L, 1)`
+ included: that call decides what sits one slot below the top, which is what
+ `luaD_seterrorobj`'s `default` branch reads. Without it, errors out of
+ `coroutine.wrap(...)` lose their `file:line:` prefix.
+* **`luaL_where` and `luaG_addinfo` answer "no debug info" in opposite ways.**
+ The former needs `currentline > 0` and otherwise emits nothing; the latter
+ prints `?:-1:` anyway. Merging them breaks both `error("x")` and the
+ `^%?:%-1:` assertion in `errors.lua`.
+* **`collectgarbage` validates its option first**, and `"generational"` /
+ `"incremental"` return the **previous mode as a string**
+ (`lbaselib.c: pushmode`), not an integer.
+* **`luaD_shrinkstack`'s `stackinuse` is the max of `top` and every
+ `ci->top`**, not `top` alone, and `stackErr` is cleared only when a shrink
+ actually happens.
+* **The main thread starts one slot up** (`stack_init` reserves it for
+ `base_ci.func`), `IoLib` must `lua_pop` after creating the std files, and
+ `Diff` must push `msghandler` onto the stack the way `lua.c: docall` does —
+ each is worth one level of recursion in `errors.lua`'s "expected stack overflow
+ after N calls".
+
+#### Four real bugs found while getting `sort.lua` / `math.lua` to finish
+
+Both files used to crash on entry; they only reach `OK` after these. None of them
+is a "we differ" — all four were plain mistakes on our side:
+
+* **`tunpack`'s overflow test must compare as unsigned.** In C, `n` is
+ `lua_Unsigned`, so a wrapping `e - i` becomes a *huge* value; a Java `long`
+ wraps to a *negative* one, the signed comparison lets it through, and the loop
+ then pushes from `minI` to `maxI` until `table.unpack` dies with
+ `OutOfMemoryError`.
+* **`top + n` overflows in `checkStack` / `checkStackOk`.** With an `n` of `2^31`
+ the sum goes negative, so the "over `MAXSTACK`" test stops firing — same
+ `OutOfMemoryError`. Now computed as `long`.
+* **`tmove`'s test must not be simplified.** C asks `e < LUA_MAXINTEGER + f`,
+ *not* `e - f < LUA_MAXINTEGER`: the two agree only when nothing wraps, and
+ wrapping is exactly what is being tested. Simplified,
+ `table.move({}, minI, maxI, 1)` reports `#4 (destination wrap around)` where
+ the reference reports `#3 (too many elements to move)`.
+* **`luaG_tointerror` carries a `varinfo` suffix.** For `math.huge << 1` the
+ reference says `number (field 'huge') has no integer representation`.
+* **`tonumber` must not use `String.trim()`.** Java's `trim` strips "characters
+ at or below U+0020", which *includes `\0`*; C recognises only `SPACECHARS`
+ (`" \f\n\r\t\v"`) and additionally requires the parse pointer to land exactly
+ on `s + l`. So both `tonumber('1\0')` and `tonumber('1\0', 2)` silently
+ returned `1`. `b_str2int` is now written over bytes and re-checks the length.
+
+#### And one more: `table.sort` picks its pivot with *unsigned* arithmetic
+
+`sort.lua` lives in the smoke set — it prints timings, so its output can never be
+compared byte for byte — and a later full run failed inside it with
+`attempt to compare nil with number` out of `table.sort`. The file seeds nothing,
+so `math.random()` hands it fresh data on every run, which makes this a *latent*
+bug: it only surfaces when a partition comes out unbalanced enough for `auxsort`
+to draw a new random pivot.
+
+* **`IdxT` is `unsigned int`, and so is the pivot seed.** C's `rnd % (r4 * 2)` is
+ an unsigned modulo, always in `[0, r4*2)`. Java's `int` is signed, and the seed
+ carries exactly the 32 bits C would have produced — negative half the time. A
+ signed modulo then drags `p` below `lo`, and at the extreme below `1`, where
+ `a[p]` is nil and `sort_comp` reports `attempt to compare nil with number`.
+ Now `Integer.remainderUnsigned`, matching C.
+* **`rnd` is passed by value.** In C it is a parameter of `auxsort`, so
+ re-randomizing inside a recursive call never leaks back to the outer frame. It
+ used to be shared through an `int[]` here, which meant a seed drawn deep in one
+ subtree went on to steer every remaining partition of the whole sort.
+
+One run in twenty is a bad way to find a bug, so `choosePivot` now carries its own
+deterministic test (`TableLibTest.choosePivotStaysInsideTheInterval`) that checks
+the C `lua_assert` — `lo + r4 <= p <= up - r4` — directly, instead of hoping a
+50 000-element sort trips over it.
+
+#### Phase 11: a precompiled chunk must load through *every* door
+
+`ldo.c: f_parser` reads one byte. If it is the first byte of `LUA_SIGNATURE`, the
+chunk goes to `luaU_undump`; otherwise to `luaY_parser`. That single test is why a
+caller never has to say whether it is handing over source or bytecode — and why
+`load`, `loadfile`, `dofile` and `require` all accept precompiled chunks for free.
+
+The dispatch used to exist only inside `BaseLib`'s `load` / `loadfile`. `lua_load`
+itself (`LuaState.load`) did not look at the signature, so **`require` of a
+precompiled module failed**:
+
+```
+error loading module 'mod' from file '/tmp/mod.lua':
+ /tmp/mod.lua:1: unexpected symbol near '<\27>'
+```
+
+The reference loads it and returns the module. `PackageLib` reaches the loader
+through `LuaState.loadFile`, which is why this showed up there and not in `load`.
+The fix is at the `lua_load` layer, not a patch on `require`:
+
+* `LuaState.load` / `loadFile` now dispatch on `Lua.LUA_SIGNATURE0`;
+* `loadFile` still runs the `lauxlib` preamble first (BOM, `#!`), because a
+ precompiled chunk never starts with `#` — order matters;
+* the binary branch does **not** push the first byte back: `luaU_undump` says
+ "skip 1st char (already read and checked)", so `checkHeader` starts at byte two.
+
+`BinaryChunkTest` covers both directions:
+
+* **chunks produced by the real `luac`** (`ReferenceLua.compile`, located via
+ `LUAC54_BIN` or sitting next to the reference `lua`), run on both sides and
+ compared byte for byte. Self-compiled chunks would be grading our own compiler
+ with our own compiler, so the reference's `luac` is what produces them. The
+ corpus covers upvalues, varargs, `goto`, mixed table constructors, integer and
+ float edges, coroutines, a 300-entry constant table, and two scripts that *fail*
+ on purpose — the error messages carry line numbers and local-variable names, so
+ losing the debug info in `undump` turns them red;
+* **chunks produced by our own `string.dump`** (`require`, `dofile`, `loadfile`,
+ the `mode` argument, the `binary string` chunk-name rule, and truncated or
+ tampered chunks, which must be load-time errors rather than crashes).
+
+#### And the fuzz corpus was 89% dead
+
+The generator had been emitting programs that do not compile:
+
+* table constructors wrote `{'key0' = v}`. Only a *name* can be a bare key, so a
+ string key needs `[k] = v` — the numeric case was bracketed, the string case was
+ not;
+* `expression` emitted `(select('#', ...))` unconditionally, while a function
+ declares `...` only 40% of the time. Outside a vararg function that is
+ `cannot use '...' outside a vararg function`.
+
+Measured with the reference `luac`: **178 of 200 seeds failed to compile.** A
+program that does not compile produces the *same* error on both sides, so the
+comparison "passes" while proving nothing — the strongest gate had been quietly
+vacuous over tens of thousands of seeds. Fixed (keys are always bracketed; the
+generator tracks whether the function being built is vararg), and re-measured:
+0 / 400.
+
+`FuzzTest.assertCompiles` now makes that state unrepresentable: it fails the seed
+whose reference output has the shape of a compile error. The discriminator is
+"the message ends in `near 'tok'`" rather than "contains `expected`", because
+runtime errors are full of `expected` (`bad argument #1 to 'upper' (string
+expected, got nil)`) and those are *supposed* to happen.
+
+#### Fuzzing the bytecode path too
+
+Ordinary fuzzing only ever takes the text path — `Dump` and `Undump` were touched
+by a few dozen hand-written cases, which is exactly the blind spot the
+`require`-a-precompiled-chunk bug lived in. `generatedChunksSurviveDumpAndUndump`
+closes it: the generated body is wrapped in a vararg function, `string.dump`ed,
+`load`ed back and run. That pushes thousands of *random* `Proto`s through
+serialization — constant-table order and varint encoding, line info, local and
+upvalue debug names, `maxstacksize`, nested protos. A sentinel print
+(`dumped true loaded true`) is asserted on, so a silent `string.dump` failure
+cannot masquerade as agreement.
+
+#### Five more things the reference binary settled
+
+The fuzz generator learned `table.*` and immediately paid for itself: five
+divergences, none of which any hand-written case had hit.
+
+* **`l_str2int`'s accumulator is `lua_Unsigned`.** In `lobject.c` the running
+ value `a` is unsigned, and the overflow guard is `a >= MAXBY10`. With a signed
+ `long`, once `a` passes 2^63 it goes *negative* and the guard stops firing, so
+ the overflow escapes: `1 + (math.mininteger .. 0)` — the operand is
+ `"-92233720368547758080"` — wraps to exactly 0 and returns `1` instead of
+ falling through to the float path (`-9.2233720368548e+19`). Now
+ `Long.compareUnsigned`.
+* **`luaG_runerror` adds `file:line:` where it is raised, not later.** `idiv` and
+ `mod` divide-by-zero go through `luaG_runerror`, which calls `luaG_addinfo` on
+ the spot. Ours threw a bare `LuaError` that only received its prefix at the
+ protection boundary, by which time the raising frame was gone — so
+ `coroutine.resume` returned the error as a *value* and it arrived without a
+ position: `false attempt to divide by zero` instead of
+ `false file:line: attempt to divide by zero`. `Arith.rawArith` now takes a
+ `LuaState` for exactly this reason.
+* **`luaH_newkey` refuses nil values.** Its first line is
+ `if (ttisnil(value)) return; /* do not insert nil values */`. Assigning nil to a
+ key that does not exist creates no node and triggers no rehash. Creating the
+ node changes the computed *array-part size*, which is a direct input to `#t`:
+ `table.move({[1]=4.0}, 0, 2, 1)` leaves `#t == 2` in the reference and `0`
+ otherwise. The nil-key and NaN-key checks sit *before* that line, so
+ `t[nil] = nil` must still raise `table index is nil`.
+* **`lauxlib.c: interror` tests `lua_isnumber`, not the type.**
+ `luaL_checkinteger` that cannot get an integer reports
+ `number has no integer representation` when `lua_isnumber(L, arg)` — true for a
+ string that parses as a number — and `number expected, got string` otherwise.
+ So `string.format("%d", "3.5")` is the former (`"3.5"` parses, 3.5 just is not
+ an integer) and `string.format("%d", "abc")` is the latter. The guard used to be
+ `v.isNumber()`, which made both the latter.
+* **`%.14g` must round once, from the exact value.** Java's
+ `String.format("%.Nf")` does not round the *value*: it rounds
+ `Double.toString`'s shortest representation, i.e. twice.
+ `math.sqrt(math.maxinteger)` is exactly `3037000499.976049900054931640625`,
+ whose shortest form is `3037000499.97605`; the 15th digit is a 5, so the second
+ rounding carries and produces `3037000499.9761`. The reference prints
+ `3037000499.976`. `StringLib` already formatted through `BigDecimal` (whose
+ `new BigDecimal(double)` is the exact binary expansion); `luaO_tostring` had its
+ own copy built on `String.format`. Two copies of one C conversion is how they
+ drifted, so `Vm.formatG` now delegates to `StringLib.generalDigits`.
+
+That last one is worth stating plainly: measured over 4602 printed floats, the
+old code was wrong on **12** — 0.26%. No hand-written case was ever going to find
+it, and no amount of upstream-suite greenness would have either. It took the
+fuzzer. `RuntimeTest.floatFormattingMatchesReferenceAcrossMagnitudes` now pins it
+with a few thousand values, generated by an LCG *inside* the script so that the
+random-number generator itself stays out of the comparison.
+
+#### And two more, from widening the corpus
+
+The generator then learned `utf8.*`, `gmatch`, `next`, `string.pack`/`unpack`,
+`table.pack`, non-string error values, `math.ult`/`fmod`/`max`/`min`, and
+`rawequal`/`rawlen`/`select`. Measured before trusting it: every new branch fires
+in 6–29% of programs, so the corpus is actually alive. It immediately found two
+more, both in `ltable.c` — the same file as three of the previous five.
+
+* **`luaH_getn` answers `limit - 1` before it ever binary-searches.** The manual
+ only asks `#t` to return *a* boundary, which makes "we found a valid boundary"
+ sound sufficient. It is not: real programs observe which one. `ltable.c` has a
+ fast path for the common case (`t[#t] = nil`, then ask for the length again):
+
+ ```c
+ if (limit > 0 && isempty(&t->array[limit - 1])) {
+ if (limit >= 2 && !isempty(&t->array[limit - 2]))
+ return limit - 1; /* (1a) do not search */
+ else { /* (1b) */ ... binsearch ... }
+ }
+ ```
+
+ An array of `[1, nil, 3, nil]`: binary search returns `1` (it finds the *first*
+ boundary), the reference returns `3`. `table.pack(1, nil, 3, nil)` leaves
+ `#t == 3`, not `1`. Across a systematic sweep of hole patterns — every single
+ hole up to `n = 32`, every hole pair up to `n = 19`, every triple up to
+ `n = 11`, all 32 `table.pack` nil combinations — **212 of 4968 results were
+ wrong (4.3%)**.
+
+* **`luaH_resize` must let the hash part shrink to *dummy*.** After
+ `rehash`, C's `reinsert` moves the old entries with `luaH_set`, so any integer
+ key `<= asize` is routed into the new array part and occupies **no hash slot**;
+ `luaH_resize(L, t, 4, 0)` therefore ends with a dummy (zero-slot) hash. This
+ port sized the hash to fit *all* the moved entries, so an array of 4 came with
+ 2 stale hash slots. That matters because `luaH_newkey` tests `isdummy(t)`: with
+ a non-dummy hash, the next insert just takes a free slot and **never rehashes**,
+ so the array part stops growing. `t = {}; t[2]=2; t[3]=3; t[4]=4; t[6]=6;
+ t[7]=7` gives the reference an array part of 8 and `#t == 7`; this port had 4
+ and 4. The sweep above is what exposed it — the previous corpus only ever
+ inserted keys `1..n` in order, which never reaches this path.
+
+Both are pinned by `RuntimeTest.tableLengthBoundaryMatchesReference`, which builds
+the hole patterns with loops inside the script (an LCG would put the generator
+itself into the comparison) and reads `#t` twice per table, because C rewrites
+`alimit` as a hint on the first call.
+
+#### One more, from running the corpus inside coroutines
+
+The same corpus was then also run through `coroutine.create`, which found a bug
+that had nothing to do with coroutines:
+
+> `pcall(math.random, 5, 1)` inside a coroutine reported
+> `bad argument #1 to '?' (interval is empty)`; the reference reports
+> `bad argument #1 to 'math.random' (interval is empty)`.
+
+* **`_LOADED` lives at `global_State` level, not per-thread.** `lauxlib.c`'s
+ `pushglobalfuncname` reverse-looks-up the current function in
+ `package.loaded`, two levels deep, to turn `'?'` into a real name:
+
+ ```c
+ static void pushglobalfuncname (lua_State *L, lua_Debug *ar) {
+ ...
+ lua_getfield(L, LUA_REGISTRYINDEX, LUA_LOADED_TABLE);
+ if (findfield(L, L.top - 1, 2)) { ... }
+ ```
+
+ `LUA_LOADED_TABLE` is a field of the *registry*, and the registry belongs to
+ `global_State` — every `lua_State` shares one. This port had made `loaded` a
+ per-`LuaState` table, so a coroutine's `package.loaded` was empty and every
+ diagnostic inside it degraded to `'?'`. The fix is one line in the
+ constructor: `loaded = main.loaded`. Note that this only shows up *inside a
+ coroutine* — the main thread populated its own table, which is why the whole
+ upstream suite and every earlier fuzz seed were silent about it.
+
+`RuntimeTest.libraryFunctionNamesResolveInsideCoroutines` pins it.
+
+#### The gate itself had a hole
+
+A systematic sweep of `string.format("%q", ...)` — every byte 0–255, in four
+positions — was the first probe that wrote **non-UTF-8 bytes** to stdout. It found
+nothing wrong with the engine, and something wrong with the harness: both sides
+captured the reference's and this port's output into a Java `String` by decoding
+UTF-8. Any invalid byte becomes `U+FFFD`, so `0x80` and `0xEF 0xBF 0xBD` compare
+*equal*:
+
+```
+>>> b'"\x80"\n'.decode('utf-8', 'replace') == b'"\xef\xbf\xbd"\n'.decode('utf-8', 'replace')
+True
+>>> b'"\x80"\n'.decode('latin-1') == b'"\xef\xbf\xbd"\n'.decode('latin-1')
+False
+```
+
+Measured rather than assumed: with `print` deliberately changed to round-trip
+through a Java `String` (i.e. corrupting every non-UTF-8 byte), the new `%q`
+sweep is **red under Latin-1 and green under UTF-8** — the old gate let a real
+byte-level corruption through. The gate now decodes Latin-1, which is a
+byte-for-char bijection, so comparisons are genuinely byte-exact.
+`RuntimeTest.quoteFormatMatchesReferenceForEveryByte` is the pin.
+
+#### And one more, from sweeping operators against types
+
+The next probe was a full matrix: every operator × every pair of types, with no
+metamethods, comparing the *error text*. 1651 lines. A second one did the same
+with metamethods present — which of the two handlers runs, `__le` falling back to
+`not (b < a)`, `__index`/`__newindex` in both table and function form, `__concat`
+right-associativity, `__close` timing and error propagation (245 lines). The
+second one was clean; the first found this:
+
+* **`luaG_tointerror` converts with `F2Ieq`, not "floor to integer".**
+ `lvm.h` defines `LUA_FLOORN2I` as `F2Ieq` by default — the name says *floor*,
+ the semantics are *"accepts only integral values"*:
+
+ ```c
+ void luaG_tointerror (lua_State *L, const TValue *p1, const TValue *p2) {
+ lua_Integer temp;
+ if (!luaV_tointegerns(p1, &temp, LUA_FLOORN2I))
+ p2 = p1; /* report the *first* operand if it cannot convert */
+ luaG_runerror(L, "number%s has no integer representation", varinfo(L, p2));
+ }
+ ```
+
+ Judging with a rounding mode makes `1.5` look convertible, so `1.5 & 3`
+ reported the **right** operand: `number (local 'b') has no integer
+ representation`, where the reference reports the left, `(local 'a')`. Ten lines
+ of the matrix were wrong. Hand-written cases only ever used `inf`/`nan`, where
+ both modes agree — which is why this survived every earlier gate.
+
+Both matrices are now permanent tests:
+`RuntimeTest.operatorTypeErrorsMatchReference` and
+`RuntimeTest.metamethodDispatchMatchesReference`.
+
+#### Two more, from sweeping the format specifiers
+
+`lstrlib.c: str_format` is one of the densest functions in the source, so the next
+probe swept it: 22 verbs × 10 flag combinations × 5 widths × 9 precisions × 22
+value types — **20777 lines**. It found two things.
+
+* **The order of the two checks.** `str_format` does *not* validate the spec
+ first. For `d i u o x X` it calls `luaL_checkinteger` **before** `checkformat`,
+ and for `f e E g G` it calls `luaL_checknumber` first:
+
+ ```c
+ case 'd': case 'i': flags = L_FMTFLAGSI; goto intcase;
+ ...
+ intcase: {
+ lua_Integer n = luaL_checkinteger(L, arg);
+ checkformat(L, form, flags, 1);
+ ```
+
+ (`c`, `a` and `s` are the other way round.) Validating the spec first meant
+ `("%.100d"):format("x")` reported `invalid conversion specification` where the
+ reference reports `number expected, got string` — **6620 lines wrong**.
+
+* **A precision turns off the `0` flag for integer verbs.** C99: *"For `d`, `i`,
+ `o`, `u`, `x`, and `X` conversions, if a precision is specified, the `0` flag
+ is ignored."* So `("%05.3d"):format(42)` is ` 042` — the precision pads to
+ `042`, then the width pads with **spaces** — not `00042`. 220 lines. Float verbs
+ have no such rule (`%08.3f` still zero-pads), so the fix is confined to
+ `emitInt`.
+
+`RuntimeTest.formatSpecifierMatrixMatchesReference` pins both; reverting either
+one alone turns it red.
+
+#### And a second copy of `luaL_checkinteger`
+
+The next sweep was `os.date` specifiers, `os.time` argument checking,
+`utf8.*` boundaries, and `tonumber` across bases 2–36 (854 lines). It found a
+familiar shape of bug: **`luaL_checkinteger` had two implementations**, and the
+older one in `BaseLib` was missing two things — the function-name prefix
+(`bad argument #2 (...)` instead of `bad argument #2 to 'tonumber' (...)`) and
+the "no integer representation" case, reporting `number expected, got number`
+for `1.5`. Four call sites were affected: the base of `tonumber`, the index of
+`select`, the level of `error`, and the index of the `ipairs` iterator. It now
+delegates to `LuaAux.checkInteger`.
+
+That sweep also turned up a second instance of "not our bug, libc disagreeing":
+**`os.date("!%z", t)`**. C's `strftime` reads the *process-global timezone*,
+not the `tm` Lua hands it, and with the `!` (UTC) flag the two libc's answer
+differently — macOS prints the local offset `+0800`, glibc prints `+0000`. The
+test therefore only exercises `%z`/`%Z` **without** `!`, where both sides run on
+the same machine and the answer is well-defined.
+
+`string.pack`/`unpack`/`packsize` also got a full sweep — every format verb
+(including `i`/`I` widths 1–16, `c`/`s` counts, `X` and `!n` alignment, all four
+byte orders) × values, plus invalid formats and out-of-range offsets, 706 lines.
+It was clean; it is now a test so that "already agrees" stays agreed.
+
+#### One thing that is *not* a divergence: subnormal `%a`
+
+`quotefloat` formats through C's `%a`:
+
+```c
+int nb = lua_number2strx(L, buff, MAX_ITEM, "%" LUA_NUMBER_FRMLEN "a", n);
+```
+
+C99 fixes one hexadecimal digit to the left of the point and says it "is `0` only
+if the argument is denormalized" — which leaves *how* a subnormal is written
+**unspecified**. glibc and Apple libc pick opposite answers:
+
+| value | glibc (CI: ubuntu) | Apple libc / musl |
+| --- | --- | --- |
+| `5e-324` | `0x0.0000000000001p-1022` | `0x1p-1074` |
+| `3 * 5e-324` | `0x0.0000000000003p-1022` | `0x1.8p-1073` |
+
+This port matches **glibc**, because the gate that matters — CI — runs on
+`ubuntu-latest`. `StringLibTest.subnormalQuoteFollowsGlibc` pins it with a plain
+assertion *outside* the differential table, so that nobody "fixes" it by matching
+a macOS reference binary and breaks Linux. (`Double.toHexString` already produces
+the glibc form, so there is nothing to implement.)
+
+#### A second one that is *not* a divergence: `%a` rounding, and `inf`/`nan` padding
+
+Apple libc is simply **wrong** when `%a` is given a precision and the first dropped
+hex digit is `8`. It is not round-to-nearest at all — its decision depends on the
+*parity of the second dropped digit*:
+
+```text
+("%.4a"):format(0x1.500082p0) -> 0x1.5000p+0 # 0x82 > 0x80, yet rounded down
+("%.4a"):format(0x1.500081p0) -> 0x1.5001p+0 # ...and this one rounds up
+```
+
+glibc's rule, straight from `stdio-common/printf_fphex.c` plus
+`include/rounding-mode.h`:
+
+```c
+bool more_bits = ((next_digit_value & 7) != 0 || precision + 1 < numend - numstr);
+do_round_away = round_away (negative, last_digit_value & 1,
+ next_digit_value >= 8, more_bits, mode);
+/* FE_TONEAREST: half_bit && (last_digit_odd || more_bits) */
+```
+
+i.e. round-to-nearest, ties-to-even — and when `precision == 0` the "last kept
+digit" is the one left of the point, so `("%.0a"):format(1.5)` is `0x2p+0`, not
+`0x1p+0`. `StringLibTest.hexFloatPrecisionRoundingFollowsGlibc` pins these with
+plain assertions; the differential sweep skips every case whose first dropped digit
+is `8` (the two sides skip exactly the same set — the predicate is computed from the
+unrounded `%a`, which both agree on).
+
+Same file, one more: `inf` and `nan` are **not** zero-padded. The `special` branch
+does `pad(buf, ' ', width)` unconditionally and never looks at `pad == '0'`, so
+`("%020.5a"):format(math.huge)` is seventeen spaces followed by `inf`.
+
+#### One more, from sweeping the `table` library: `tmove` dropped a condition
+
+The `table` sweep — `insert` / `remove` / `move` / `concat` / `unpack` / `pack` /
+`sort` × positions × sizes × offsets × value types, including same-table
+overlapping `move` and cross-table `move`, **6277 lines** — produced 268 diff
+lines, and every one of them had an unsettling shape: **the table's contents were
+identical, only `#t` differed.**
+
+```text
+< MV2-dump #0 -2=nil,-1=nil,0=nil,1=nil,2=1,3=nil,4=nil,...
+> MV2-dump #2 -2=nil,-1=nil,0=nil,1=nil,2=1,3=nil,4=nil,...
+```
+
+The cause is a missing clause. C decides the copy direction like this:
+
+```c
+if (t > e || t <= f || (tt != 1 && !lua_compare(L, 1, tt, LUA_OPEQ))) {
+ for (i = 0; i < n; i++) { ... } /* forward */
+} else {
+ for (i = n - 1; i >= 0; i--) { ... } /* backward */
+}
+```
+
+This port had only the first two clauses. The third one is not about positions at
+all: **when the source and destination are different tables, the copy is always
+forward**, regardless of how `t` and `f` relate. Dropping it sends cases that
+should go forward down the backward path.
+
+Order does not change what ends up in the table — but it changes the *layout*,
+which is what `#t` reads. `table.move({1}, -2, 1, -1, {1})` leaves the destination
+with only `[2] = 1` either way; going forward clears `[1]` first and leaves
+`alimit` at 1, so `luaH_getn`'s first case ("`array[alimit-1]` is nil ⇒ binary
+search inside the array part only") fires and the answer is `0`. Going backward
+sets `[2]` first, pushing `alimit` to 2, that case no longer applies, and the
+answer becomes `2`. Hence the confusing symptom: the same sequence of writes
+agrees when driven through `rawset` and disagrees when driven through
+`table.move`, because only the internal layout differs.
+
+Two lessons went into the tests:
+
+* **Comparing table contents is not enough — `#t` has to be compared too**, or
+ layout bugs are invisible.
+* The sweep is now three permanent tests:
+ `RuntimeTest.tableLibraryMatrixMatchesReference` (6277 lines, the one that
+ found it), `RuntimeTest.patternMatchingMatrixMatchesReference` (24819 lines of
+ pattern matching × subject × `init` × replacement form — **clean**, pinned so
+ that "already agrees" keeps agreeing), and
+ `RuntimeTest.tableLengthAcrossInsertionHistoriesMatchesReference` (3736 lines
+ that make *insertion history* itself the sweep dimension: every subset of
+ `1..n` × three insertion orders, fill-then-punch, `move`-built layout,
+ `insert`/`remove` alternation, negative/zero keys, bisection up to 40,
+ tail truncation — also clean).
+
+Reverting the one clause turns the first of them red.
+
+#### One more, from sweeping `debug` / `load` / `require`: `xpcall` skipped its check
+
+The next sweep — `debug.getinfo` (14 fields × levels 0–6), `getlocal`/`setlocal`,
+the upvalue family, `traceback` (including inside a coroutine), `sethook` with 10
+masks plus the count hook, `load` (source / reader function / mode / env / binary
+chunk), `package.path` / `cpath` / `config` / `searchers` and `require`'s odd
+corners, `error` at levels 0–4, `xpcall`, `assert`, and error propagation through
+coroutines, 289 lines — found **`xpcall` had lost its argument check**:
+
+```c
+static int luaB_xpcall (lua_State *L) {
+ ...
+ luaL_checktype(L, 2, LUA_TFUNCTION); /* check error function */
+```
+
+C checks that the message handler is a function *before* calling anything. This
+port only checked that two arguments were present, so `xpcall(f, 5)` did not
+raise — it called `f`, and when `f` errored it tried to use `5` as the handler:
+
+| | reference | this port, before |
+| --- | --- | --- |
+| `xpcall(f, 5)` | `false bad argument #2 to 'xpcall' (function expected, got number)` | `true false error in error handling` |
+| `xpcall()` | `false bad argument #2 to 'xpcall' (function expected, got no value)` | `false bad argument #2 to 'xpcall' (value expected)` |
+
+The `io` sweep also went in — 32 open modes (including every invalid one), every
+`read` format and byte counts 0–24, numeric reads, `seek`, `lines`, using a
+closed file, `io.input`/`output`/`read`/`write`/`close`, append-mode `seek`
+semantics, `r+` interleaving, a 5000-byte block, and the same file opened twice,
+167 lines. It was clean, and it is worth having anyway: it is the only thing that
+exercises the **errno layer** — a failed `io.open` must return
+`"path: No such file or directory"` plus errno `2`, which C gets from `strerror`
+and we have to match on our own.
+
+#### A second hole in the gate: tracebacks silently truncated the output
+
+The `debug` matrix was green on the first attempt — with the `xpcall` bug still in
+the code. That was the gate failing, not the code passing.
+
+`Diff.assertMatches` normalizes by cutting everything from the first
+`stack traceback:` onwards. That is deliberate: when a *script crashes*, the
+message comes first and the traceback last, and our `ldebug.c` cannot be expected
+to match it line for line. But when the traceback is **printed by the script** —
+`debug.traceback()`'s return value — it lands in the *middle*, and everything the
+script printed afterwards was thrown away. This script prints a traceback in its
+fifth section and has 180 lines after it.
+
+The guard now rejects that case: if anything other than traceback lines (each
+starts with a tab) follows the traceback, `assertMatches` fails and says to use
+`assertFullMatches`, which does not strip. Thirteen existing tests turned out to
+have tracebacks in their output; the refined check — content *after* the
+traceback, not merely its presence — leaves all of them alone, because in those
+the traceback really is last.
+
+This is the second time the gate has been found blind rather than the code wrong
+(the first was UTF-8 decoding of captured output). A gate that quietly compares
+less than it claims is worse than no gate: it reports green.
+
+#### One the fuzzer found, where the bug was in the *harness*
+
+At scale (16500 generated programs) the fuzzer produced a program whose stack
+overflow was reported on **line 67** by the reference and **line 65** by this
+port. The engine was fine; the harness was one stack slot short:
+
+```c
+ lua_pushcfunction(L, &pmain); /* to call 'pmain' in protected mode */
+ lua_pushinteger(L, argc); /* 1st argument */
+ lua_pushlightuserdata(L, argv); /* 2nd argument */
+ status = lua_pcall(L, 2, 1, 0); /* do the call */
+```
+
+`lua.c`'s `main` passes `pmain` **two** arguments, so the stack's initial `top`
+is 3. `Diff.runLikeLuaC` passed one. One missing slot is carried all the way
+down: the recursion that overflows the stack goes one level deeper, so the
+innermost frame is a different function, so the reported line differs.
+
+The reason it took a while to see: it does **not** always differ. Each frame
+costs `k + 1` slots (`k` = number of locals) and the depth is
+`floor((limit - base) / (k + 1))`, so a base one slot off only changes the answer
+when it happens to cross a division boundary. Measured across frame sizes:
+
+| locals `k` | 0 | 1 | 2 | 3 | 4 | 6 | 8 | 16 | 32 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| differed before | yes | yes | yes | no | no | yes | no | no | no |
+
+Trying one example would have looked fine. Fixing the harness makes all nine
+match, and `RuntimeTest.stackOverflowDepthAndLineMatchReference` now sweeps them.
+
+(The other trap in that probe: `local function f() return f() end` is a **tail
+call**, consumes no stack, and loops forever instead of overflowing — the
+reference binary had to be killed after five minutes. The probe uses
+`return 1 + f()`.)
+
+#### And three sweeps that came back clean
+
+Not every sweep finds something. `math` (every function × 30 boundary values
+including ±inf, nan, −0.0, `maxinteger`, `mininteger`, `2^53`, `2^63`, `1/3`; both
+return values of `modf`; `log`'s base; six kinds of argument error per function;
+the seeded random sequence) plus the deterministic parts of `os`, **1423 lines**,
+is clean. It stays in the suite for three reasons:
+
+* **The last bit of floating point.** `math.sin` and friends go through `libm`;
+ `Math.sin` need not agree bit for bit. Default `tostring` prints 14 significant
+ digits, which happens to hide it — the sweep proves it is hidden, rather than
+ never having been looked at.
+* **`math.random`'s sequence** — xoshiro256\*\* pins it exactly, so a seeded run
+ must match number for number.
+* **The `errno` / `strerror` layer** again, on `os.remove` / `os.rename`.
+
+Three things there cannot be tested at all: `os.clock` (elapsed time), a
+`math.randomseed` with no argument (seeds from the clock), and `os.time` with no
+argument (current time).
+
+One catch while landing it: `math.atan` and `math.atan2` are **the same**
+`math_atan` in `lmathlib.c`, so which name shows up in the error message is
+decided by whichever key `lauxlib.c: findfield` runs into first while walking
+`package.loaded` — and that order comes from the string hash, which is seeded by
+`luai_makeseed` (time-based). The reference binary is **nondeterministic** here:
+
+```
+$ for i in 1 2 3; do lua -e 'print(pcall(math.atan))'; done
+false bad argument #1 to 'math.atan2' (number expected, got no value)
+false bad argument #1 to 'math.atan2' (number expected, got no value)
+false bad argument #1 to 'math.atan' (number expected, got no value)
+```
+
+Six `atan2` and two `atan` out of eight runs. We are deterministic (the hash is
+not seeded) and that is a deliberate deviation. Sweeping the zero-argument error
+name of **every** function in `math`, `string`, `table`, `os`, `io`, `coroutine`
+and `debug`, eight runs each, `atan` is the only one that moves; the script now
+normalises it before comparing.
+
+#### The `string` sweep: two bugs, and one thing the reference disagrees with itself about
+
+The non-pattern-matching half of the `string` library — `sub`, `byte`, `char`,
+`rep`, `upper`, `lower`, `len`, `reverse`, `format`, `tonumber(base)`, `tostring`
+(pattern semantics are covered separately by the 24819-line sweep) — **3921
+lines**, and it found two:
+
+**`__name` follows C string rules.**
+
+```c
+ int tt = luaL_getmetafield(L, idx, "__name");
+ const char *kind = (tt == LUA_TSTRING) ? lua_tostring(L, -1) : ...;
+ lua_pushfstring(L, "%s: %p", kind, lua_topointer(L, idx));
+```
+
+`lua_tostring` hands back a `const char *` and `"%s"` treats it as a C string, so
+the name is cut at the first `\0`: with `__name = "A\0B"` the reference prints
+just `A`, while we printed the whole `"A\0B"`. Lua strings are not
+NUL-terminated, C strings are, and here C wins. Fixed with
+`LuaString.toCString()`, for the places where the C code uses a value as a
+`const char *`.
+
+**`%p` does not zero-pad.** The address was formatted `0x%016x`; the reference
+prints `0x7ab30002c0`, with no leading zeros (neither macOS nor glibc pads).
+
+**And one thing that cannot be compared at all:** with the `+` or space flag on a
+NaN, macOS libc prints no sign (`nan`) while glibc prints one (`+nan`). CI's
+conformance job runs on ubuntu, this machine runs macOS, so the **reference
+itself** differs between the two. The script strips NaN's sign before comparing;
+the implementation stays glibc-compatible.
+
+#### The optional-argument sweep: two checks in the wrong order
+
+The previous sweep only tried zero or one argument. This one permutes the second
+and third arguments too: for ~70 functions, every combination of `{nil, 1, "x"}`
+from zero arguments up to that function's arity — **1866 lines**. It found two,
+and both are the same mistake: **the order of the argument checks**.
+
+**`string.gsub`:**
+
+```c
+ int tr = lua_type(L, 3); /* just record the type */
+ lua_Integer max_s = luaL_optinteger(L, 4, srcl + 1);
+ ...
+ luaL_argexpected(L, tr == LUA_TNUMBER || tr == LUA_TSTRING ||
+ tr == LUA_TFUNCTION || tr == LUA_TTABLE, 3,
+ "string/function/table");
+```
+
+The fourth argument is checked **first**. So `gsub(s, p, nil, "x")` reports
+`bad argument #4 to 'gsub' (number expected, got string)`; we checked argument 3
+first and reported `bad argument #3 (string/function/table expected, got nil)`.
+
+**`tonumber`:** the three checks run `luaL_checkinteger(2)` →
+`luaL_checktype(1, LUA_TSTRING)` → `luaL_argcheck(2 <= base <= 36, ...)` — the
+range check comes **last**:
+
+| | reference | before the fix |
+| --- | --- | --- |
+| `tonumber(nil, 1)` | `bad argument #1 to 'tonumber' (string expected, got nil)` | `bad argument #2 (base out of range)` |
+| `tonumber(1, 1)` | `bad argument #1 to 'tonumber' (string expected, got number)` | `bad argument #2 (base out of range)` |
+
+Three functions are deliberately absent: `os.time` and `os.date` read the clock
+when their first argument is `nil`, `math.randomseed(nil)` seeds from the clock,
+and `io.lines` with no argument reads stdin.
+
+#### The `package` / `require` / `load` sweep: two bugs, and one counter that has to be shared
+
+**311 lines.** `package`'s static structure, `searchpath`, every shape of
+`require` (module returning `nil` / `false` / `true` / nothing, caching, a module
+that raises, circular dependency, `preload` winning over disk, names with
+slashes), and `load`'s `mode` / `env` / `chunkname` / reader functions.
+
+**`package.loadlib`'s third return value says *where* it failed, not *that* it
+failed.** `ERRLIB` (the library could not be opened) gives `"open"`, `ERRFUNC`
+(the symbol is not in it) gives `"absent"`:
+
+```c
+ lua_pushliteral(L, (stat == ERRLIB) ? "open" : "absent");
+```
+
+We had it hard-coded to `"absent"`, copying the fallback branch for builds
+without `dlopen` — but in that branch `lookforfunc` always returns `ERRLIB`, so
+`"open"` is what it should have been all along. Code that uses the third value
+to decide whether to keep looking picks the wrong branch.
+
+**A circular `require` does not report a bare `C stack overflow`.** It reports:
+
+```
+error loading module '_pk_b' from file './_pk_b.lua':
+ C stack overflow
+```
+
+because the parser and `luaD_call` **share one counter**: `#define enterlevel(ls)
+luaE_incCstack(ls->L)`. So the 200th level is not reached at a `lua_call` — it is
+reached in the middle of some `luaL_loadfile`, the error is caught by
+`luaD_protectedparser`, `loadfile` returns a non-`OK` status, and `checkload`
+wraps it. Counting call depth and expression depth separately gives the naked
+message instead. The sweep pins the exact shape, module name included.
+
+Fixing that exposed the counter's other half: **the rollback**. `luaD_call`
+increments `nCcalls` *before* the `try`-equivalent, so when `luaE_checkcstack`
+raises, the increment is outside the `finally`; C gets it back from
+`luaD_rawrunprotected`'s `L->nCcalls = oldnCcalls` at the `pcall` boundary, and
+Java has no `longjmp` to fall back on. Without the rollback every C-stack
+overflow leaks one level forever — measured with a `pcall`-recursion probe, the
+quota walks 197 → 196 → 195 → 194 → 193, and upstream `errors.lua` starts
+reporting `error in error handling` where the reference prints `OK`. Same for the
+parser: the restore has to sit on `lua_load`, not on the binary/text dispatch
+below it, or a failed `load` leaks two levels each time.
+
+**And one that cannot be compared:** `package.loadlib`'s failure *message* comes
+from the platform `libc` (`dlopen(...)` on macOS, `cannot open shared object
+file...` on glibc), and this implementation does not load dynamic libraries at
+all — three different strings, so the sweep compares only that it is a string.
+
+#### The deep `io` sweep: `fseek` owes you `EINVAL`
+
+`io` was the thinnest of the ten standard libraries at 167 lines. This one adds
+**124 lines** of output: sixteen legal `open` modes and nine illegal ones (which
+*raise* rather than returning `nil` + message — `l_checkmode` wants `[rwa]%+?b?`),
+every `read` format, how `write` renders integers versus floats, all three
+`seek` origins and their out-of-range cases, `lines`, `setvbuf`, closing twice,
+`io.output` / `io.input` switching, and binary safety with `\0` and invalid UTF-8.
+
+It found one thing: **a negative target position is `EINVAL`, not a generic I/O
+error.**
+
+```
+reference: nil Invalid argument 22 -- seek("set", -1)
+before: nil Input/output error 5
+```
+
+`fseek` gives `EINVAL` for a negative resulting offset from *all three* origins
+(`SET`, `CUR`, `END`), while Java's `RandomAccessFile.seek` throws *"Negative
+seek offset"*, which falls through `errnoOf`'s catch-all into `EIO`. Seeking
+**past** EOF is legal and returns the new position — easy to block by accident
+while fixing this.
+
+One thing is deliberately absent: `print` writes to the C-level `stdout`, so it
+is *not* redirected by `io.output` — a script that assumes otherwise produces two
+different wrong answers.
+
+`fopen` on a **directory** used to be listed here as absent too, and that was a
+mistake: it is a single POSIX rule (`open(dir, O_RDONLY)` succeeds, `O_WRONLY`
+gives `EISDIR`, and the first read fails with `EISDIR`), it is the same on macOS
+and glibc, and the JVM simply refused to model it. It is now modelled — see
+*The directory bug* below. The one part that is genuinely not comparable is
+`seek("end")` on a directory, which returns the directory's own size (64 here),
+a filesystem fact rather than a Lua one.
+
+#### The `os.time` sweep: `lua_tointegerx` converts strings
+
+The 854-line sweep covered every `os.date` conversion specifier. This one covers
+the half it did not: `os.time` with tables (leap-year carry, month/day/hour out
+of range, defaults, ignored extra fields), `difftime`, `getenv`, `remove` /
+`rename`, `tmpname`, `execute`'s exit codes, `setlocale`.
+
+**The fields of a date table are read with `lua_tointegerx`, which converts
+strings.**
+
+```c
+ lua_Integer res = lua_tointegerx(L, -1, &isnum); /* cvt2num: strings too */
+```
+
+So `os.time{year = "2000", month = 1, day = 1}` *is* the year 2000; we reported
+`field 'year' is not an integer`. Library arguments go through the coercing
+`luaL_check*` family, while the VM's arithmetic instructions deliberately do not
+coerce — two different rules, easy to write as one.
+
+The sweep also pins down that `month` and `day` are **mandatory** (`field 'month'
+missing in date table`; `year` defaults and `hour` defaults to 12), that a field
+too large for the `int` range reports `field 'x' is out-of-bound`, and that extra
+fields (`yday`, `wday`, …) are ignored.
+
+One thing is deliberately absent: extreme years such as `year = -100`, because
+`mktime`'s representable range is libc-specific (macOS reports *"time result
+cannot be represented in this installation"*).
+
+Removing a **directory** used to be absent too, on the grounds that `ENOTEMPTY`
+is 66 on macOS and 39 on glibc — but "the number differs per OS" is a reason to
+*key off the OS*, not to skip the case. It is now compared, with `ENOTEMPTY`
+resolved from `os.name`; see *The errno shape table* below. (Skipping it had
+hidden that the errno was wrong outright: it was hard-coded to 39 everywhere.)
+
+#### The weak-table / `__gc` / `` sweep: `isdecGCmodegen`
+
+This is the semantically riskiest corner of the whole project: **there is no
+collector** (`lgc.c` is not ported), so weak tables are rebuilt on
+`WeakReference` and `__gc` on `Cleaner`. It deserves more than twelve hand-written
+tests. The sweep covers weak keys / values / both, keys that are still strongly
+reachable, mutually-referencing weak values, values that are not collectable at
+all (strings, numbers, booleans), every spelling of `__mode`, `__gc` on one and on
+twenty objects, `__gc` raising, `__gc` added *after* `setmetatable` (which C
+ignores — it only checks at that moment), and ``'s ordering and error
+argument.
+
+It found one thing: **`collectgarbage("generational")` / `("incremental")` report
+`isdecGCmodegen`, not `gckind`.**
+
+```c
+#define isdecGCmodegen(g) (g->gckind == KGC_GEN || g->lastatomic != 0)
+```
+
+`lastatomic` is non-zero after the first real collection, and loading a script
+allocates, so user code starts out looking *generational* even though `gckind` is
+`KGC_INC`. Five consecutive switches in the reference:
+
+```
+1 generational 2 incremental 3 incremental 4 generational 5 incremental
+```
+
+Tracking only `gckind` gets the **first** one wrong. Both switches clear
+`lastatomic`, so afterwards `gckind` alone decides.
+
+Two things cannot be compared: `collectgarbage("count")` (nothing to count
+without a collector) and `collectgarbage("step")`'s return value — a single step
+in the reference never finishes a cycle, so it is always `false`; with no
+incremental collector one step *is* a full round, so we return `true`.
+Deliberately: returning `false` would make the `repeat until
+collectgarbage("step")` idiom spin forever.
+
+#### The `debug` × coroutine sweep: `_LOADED` lives in the registry
+
+Both sides had been swept separately (114 and 99 lines), but not the
+**cross-product**: `getinfo` / `getlocal` / `traceback` / `sethook` *on a
+coroutine*, hook **counts** (printed as a number instead of one line per event —
+the count itself is a strong check on the line table and the call sequence, and it
+pins the `tail call` hook event), registry / `uservalue` / `setmetatable`, and the
+rest of the coroutine state machine.
+
+It found one thing: **`_LOADED` has to be a field of the registry.**
+
+```c
+#define LUA_LOADED_TABLE "_LOADED" /* lauxlib.h */
+```
+
+`package.loaded` is not stored somewhere else with a copy in the registry — it
+*is* the registry field, so `debug.getregistry()["_LOADED"]` must return it. We
+kept it in a `LuaState` field only, so `type(debug.getregistry()["_LOADED"])`
+gave `nil` where the reference gives `table`. Now it is read back out of the
+registry, so a script that swaps `_LOADED` swaps ours too.
+
+#### The error sweep: two things about `__name`
+
+208 lines on one axis that no earlier matrix had touched: `error(msg, level)` for
+`level` from `-1` to `math.maxinteger`, error **objects** of every type (and what
+`print` / `tostring` / `string.format("%s")` do to them), the `luaL_where`
+position prefix in every syntactic position that can raise (table constructor,
+numeric and generic `for`, all twelve metamethods, a `gsub` replacement, a `sort`
+comparator, a coroutine, ``, a method, an `__index` chain, a tail call, a
+`load`ed chunk with `=` / `@` / string names), the shape of `xpcall`, errors
+inside hooks, and chunks dumped with `strip`.
+
+It found two things, both in `luaT_objtypename`:
+
+```c
+const char *luaT_objtypename (lua_State *L, const TValue *o) {
+ Table *mt;
+ if ((ttistable(o) && (mt = hvalue(o)->metatable) != NULL) ||
+ (ttisfulluserdata(o) && (mt = uvalue(o)->metatable) != NULL)) {
+ const TValue *name = luaH_getshortstr(mt, luaS_new(L, "__name"));
+ if (ttisstring(name)) /* is '__name' a string? */
+ return getstr(tsvalue(name)); /* use it as type name */
+ }
+ return ttypename(ttype(o)); /* else use standard type name */
+}
+```
+
+**`__name` is a C string.** `getstr()` hands back a `const char *` and
+`luaG_typeerror` interpolates it with `%s`, so it is cut at the first `\0`:
+`__name = "A\0B"` reports `attempt to perform arithmetic on a A value`. We
+returned the whole byte array. (The same rule was already applied on
+`luaL_tolstring`'s path — this was the second copy of the bug.)
+
+**Only tables and (full) userdata consult `__name`.** Everything else falls
+straight to `ttypename`, *type metatable and all*: after
+`debug.setmetatable(nil, {__name = "NilT"})`, `nil + 1` still reports
+`... on a nil value`. Our lookup went through `luaT_gettmbyobj`, which also
+returns the *type* metatable — so we reported `NilT`.
+
+Four more things the sweep pinned down, none of which were bugs:
+
+* **`debug.traceback` returns a non-string, non-`nil` message untouched**
+ (`ldblib.c: db_traceback`), so `xpcall(f, debug.traceback)` with a table error
+ object yields the *table*, not a traceback. And `lua_tostring` does **not**
+ honour `__tostring` — which is exactly why `lua.c`'s `msghandler` has to call
+ `luaL_callmeta(L, 1, "__tostring")` by hand.
+* **The line hook fires before the first instruction of the line**, so
+ `print(pcall(f))` written on the same line does *not* protect the hook's error;
+ the call hook fires for `print` and `pcall` themselves.
+* **`xpcall` drops the handler's second result**: `ldo.c: dothecall` calls it as
+ `luaD_callnoyield(L, L->top - 2, 1)` — `nresults` is hard-coded to 1.
+* `coroutine.wrap`'s error carries **two** position prefixes; `pcall(error, "x")`
+ has **none** (a C function has no line); a library error **does** have one,
+ because `luaL_error`'s `luaL_where(L, 1)` asks for the *caller*.
+
+#### The NUL sweep: thirteen places C cuts a Lua string at `\0`
+
+One axis, one rule: a Lua string is a byte string, but every C API that takes a
+`const char *` — `strlen`, `%s`, `strstr`, `strchr`, `strcmp` — stops at the
+first NUL. 88 lines, and it found thirteen divergences, all of them the same bug
+in thirteen different doorways:
+
+| cut at the first `\0` | chunk name (`luaS_new`), module name and `package.loaded`'s key, all four `searchpath` arguments, `io.open` / `io.lines` / `os.remove` / `os.rename` / `os.getenv` filename, `fopen`'s *mode*, `debug.getinfo`'s `what`, `debug.sethook`'s mask, `luaL_traceback`'s message, `luaL_checkoption` (`collectgarbage`), `string.pack`'s format, and `__name` |
+| --- | --- |
+| **not** cut | `string.format("%s")` (goes through `luaL_tolstring`, which carries the length), `error`'s message (`lua_concat`), `os.date` (it iterates `s + slen`), table keys, `gsub`, `upper` |
+| **rejected** instead of cut | `%s` *with modifiers*, and `string.pack`'s `z` — `luaL_argcheck(L, l == strlen(s), arg, "string contains zeros")` |
+
+Two of them are worth calling out.
+
+**`os.remove("/x\0y")` crashed the whole process.** `Paths.get` throws
+`InvalidPathException`, which is a `RuntimeException` — not a `LuaError`, so
+`pcall` cannot catch it (only `LuaError` is) and it escapes all the way out.
+Truncating to C-string semantics removes the case entirely; the `catch` now also
+covers `RuntimeException` as a backstop.
+
+**`__name` was the second copy.** `luaL_typeerror` and `luaG_typeerror` both go
+through `luaT_objtypename`, but `typeArgError` had its own `metafieldOf(v,
+"__name")` lookup — which got *both* rules wrong (no truncation, and no
+table/userdata restriction). It now calls `LuaTm.objTypeName` like the other two.
+
+The two `debug` ones are the subtlest: `debug.getinfo(f, "S\0ln")` must **not**
+report `invalid option` (C stops scanning at the NUL, so it never sees `l` and
+`n` at all), and `debug.sethook(f, "l\0c")` installs only the line hook while
+`"c\0l"` installs only the call hook — the counts come out inverted.
+
+#### …and a second NUL round: `load` / `loadfile` / `io.output`
+
+Auditing every remaining `.toJavaString()` in `src/main` turned up three more
+doorways — `load`'s *mode* (`checkmode` compares `const char *`), `loadfile`'s
+filename, and the filename handed to `io.output` / `io.input`. So
+`load(src, name, "t\0b")` has mode `"t"`, and `load(src, name, "b\0t")` tries to
+undump a text chunk and reports `attempt to load a text chunk (mode is 'b')`.
+
+It also found one place where C and Java simply disagree about a path:
+**`loadfile("")`.** C's `fopen("")` fails with `ENOENT`, giving
+`cannot open : No such file or directory`; Java resolves the empty path to the
+current directory and fails while *reading*, giving `Is a directory` — which we
+turned into `Input/output error`. The empty path is the only case where the two
+judge a path differently, so it gets `ENOENT` by C's rule.
+
+Directories were claimed here as "fine, must not be fixed" — and that claim was
+wrong, which is the point worth keeping: **it was written from reading the C
+source, not from running the reference.** `loadfile("/tmp")` does succeed at
+`fopen` in C and fail at *read* — but this implementation was reporting
+`cannot open /tmp: Input/output error`, i.e. the *other* branch, because Java's
+`Files.readAllBytes` throws at open. Measuring it (see *The directory bug*) turned
+one wrong sentence into a real bug across five entry points.
+
+#### A sweep that came back clean: `load`'s reader contract
+
+54 lines on `lua_load`'s `lua_Reader`, which nothing had swept: one shot versus
+one byte at a time versus arbitrary chunk splits, **empty strings are *not* the
+end** (`Zio` asks again; only `nil` terminates, so `{"", "", src}` compiles as
+`src` while `{"", ""}` compiles as an *empty chunk*), a reader returning
+`true` / a table / a function terminates the load, a reader that raises
+propagates even when half the source is already consumed, a reader cannot
+`yield` (`lua_load` uses `luaD_callnoyield`), and `load` — unlike `loadfile` —
+does **not** eat a BOM or a `#!` line (that belongs to the loading layer, not the
+lexer).
+
+It found nothing, which is itself the point: the reader is where a byte-stream
+translation is most likely to lose or duplicate a byte.
+
+#### Two sweeps that came back clean: coroutines, and numeric `for` with coercion
+
+**Coroutines (99 lines):** which boundaries `yield` may cross, the semantics of
+`close`, how errors propagate, the four states, and nested `resume`. The split,
+measured and matching:
+
+| may `yield` | `pcall` / `xpcall`, numeric and generic `for` (custom iterators too), `gmatch`, `__index` / `__newindex` / `__add` / `__lt` / `__concat` / `__len` / `__call` / `__close` |
+| --- | --- |
+| may not | a `table.sort` comparator, a `string.gsub` replacement function, `__tostring` inside `print` — `attempt to yield across a C-call boundary` |
+
+It also pins down that a coroutine is **dead** (not suspended) after yielding
+inside `__eq`, that closing yourself reports `cannot close a running coroutine`,
+that `` variables are closed in **reverse order of declaration**, and that
+an error raised inside `__close` replaces the original error.
+
+**Syntax and scope (121 lines):** sixty-odd fragments pushed through `load()`, so
+**compile-time** errors are part of the comparison too — `goto` into a local's
+scope, duplicate labels, assigning to a ``, `break` outside a loop,
+malformed numerals and escapes, the various shapes of function names. Nothing a
+generated program will ever produce. The runtime half covers "the right-hand side
+is evaluated first" in multiple assignment, table-constructor evaluation order and
+key overwriting, varargs with `nil` mixed in, malformed uses of `:`, upvalue
+sharing, `repeat`'s condition seeing locals declared in the body, and operator
+precedence and associativity.
+
+**`debug` and `utf8` (114 lines):** every `what` letter of `getinfo` alone and in
+combination, out-of-range `getlocal` / `setlocal` / `getupvalue` / `setupvalue`,
+all four `sethook` masks, the **shape** of `traceback` (not compared wholesale —
+it contains chunk names and addresses), and the boundary cases of all five `utf8`
+functions.
+
+**Numeric `for` and coercion (551 lines):** all four `forprep` / `forloop`
+branches (integer up/down, float up/down), including `maxinteger` bounds,
+strings as limits, a zero step, and float steps; plus `//`, `%`, bitwise
+operations and string coercion. Three things worth remembering: in
+`for i = 0, 1, 0.5` the first value is an **integer** `0` (with `1.0` as the
+limit it is float throughout); bitwise operators do **not** coerce strings
+(`"10" // 2` is 5 but `"10" & 1` errors); and `for i = 2^53, 2^53+2` /
+`for i = 1e300, 1e300` never advance under float precision — the probe has a
+20000-iteration guard so both sides bail out at the same place.
+
+#### The audit that found a whole missing function: `warn`
+
+This one is different from every sweep above: those look for *divergences inside
+functions I already wrote*. Cross-checking `base_funcs[]` in `lbaselib.c` against
+`BaseLib.open` line by line found that **`warn` was not implemented at all** —
+with 828 tests green, because not one of them ever called it. A behaviour matrix
+is blind to an absent function by construction.
+
+So there are now two things: the implementation, and a **permanent gate** that
+compares the *symbol set* (190 lines: every global, every key of all nine
+libraries, the string metatable, `package.loaded`, each printed as `name type`).
+Values are not compared — they are addresses, and after normalisation nothing is
+left, which would quietly turn the test into the empty case `assertFullMatches`
+already rejects.
+
+The same audit caught **`_VERSION`**, a second whole gap. It is `"Lua 5.4"`, not
+`"Lua 5.4.7"` (`LUA_VERSION` has no release number; `LUA_RELEASE` is what
+`lua -v` prints). It matters more than it looks: real projects probe
+`if _VERSION == "Lua 5.1" then …`, so a missing `_VERSION` takes the `else`
+branch — silently wrong rather than loudly broken.
+
+The third hit, `arg`, is **not** a library gap: `lua.c` builds it in `createargtable`
+after `luaL_openlibs`. Since the differential driver already imitates `lua.c`'s
+shape (`pmain`, `msghandler`, the extra stack slot), it now imitates this too —
+otherwise no script that reads `arg[1]` can ever be compared.
+
+Implementing `warn` meant porting `lauxlib.c`'s three-state machine, and the
+probe caught three things that a `warn("@on")` test can never reach:
+
+| rule | symptom if missed |
+| --- | --- |
+| `checkcontrol` requires `tocont == 0` — so `warn("@on", "x")` prints `@onsecond` literally | "on" swallowed as a control message |
+| only **OFF and ON** consult `checkcontrol`; `warnfcont` writes straight through — `warn("a", "@on")` prints `a@on` | a second `Lua warning: ` prefix inside one message |
+| the message is a `const char *` and `lua_writestringerror` writes **bytes** | `warn("@on\0junk")` stops turning warnings on; `warn("\255")` comes out as `0xC3 0xBF` |
+
+That last pair is the NUL rule and the byte rule from earlier sections, arriving
+together in one function — which is the reason to sweep by *contract* rather than
+by example.
+
+#### What the symbol gate caught next: `f:write("")` on a read-only file
+
+The gate had a hole of its own: it compared the `io` table, so it never saw the
+**seven file methods** — `close`, `flush`, `lines`, `read`, `seek`, `setvbuf`,
+`write`. They live in `getmetatable(io.stdout).__index` (alongside `__gc`,
+`__close`, `__name` = `FILE*`, `__tostring`), not in `io`. A symbol audit has to
+follow `__index` to be worth anything; it now does.
+
+Probing those methods then turned up a real divergence:
+**`fwrite(s, 1, 0, f)` returns 0, which equals `l`, so writing zero bytes always
+succeeds** — `f:write("")` on a *read-only* file returns the file itself rather
+than `nil, "Bad file descriptor", 9`. Our `write` checked `!writable` before
+looking at the length.
+
+Only one cell of the grid exposes it, which is the argument for sweeping grids
+rather than cases:
+
+| | writable file | read-only file |
+| --- | --- | --- |
+| `f:write("")` | file (both) | **file** / ~~`nil, msg, 9`~~ |
+| `f:write("a")` | file (both) | `nil, msg, 9` (both) |
+
+Read is asymmetric and was already correct — and must stay that way: `f:read(0)`
+does not go through `read_chars` but through `test_eof` (`getc` then `ungetc`),
+so it *does* fail on a write-only stream, and `read` short-circuits on
+`done >= len` before it ever checks `readable`.
+
+Also measured and matching: `flush`, `setvbuf` and `seek` all succeed on a
+read-only stream; `io.stdout:close()` gives `nil, "cannot close standard file"`.
+
+#### Two sweeps over *every* library function at once
+
+The `f:write("")` bug was a **return-arity** bug, and it was found by hand. So
+there are now two mechanical sweeps that apply one uniform input to all ~130
+library functions and compare success/failure, the *type* of every return value,
+and the error text:
+
+* **zero arguments** (~120 lines) — the "missing argument" path;
+* **one wrong-typed argument** (`{}`, `nil`, `1`; 359 lines) — the "type error"
+ path, which is a *different* C function in `lauxlib.c` (`luaL_check*` versus
+ `luaL_opt*` treat absence differently).
+
+The zero-argument sweep found nothing new. The wrong-argument sweep came back
+clean too. They are kept because they pin ~370 call shapes at once: change any
+error text, any result count, or drop one argument check and they go red, in
+corners no hand-written matrix visits.
+
+Four things have to be excluded or normalised, each learned the hard way:
+
+| | why |
+| --- | --- |
+| `os.exit` | really kills the process |
+| `io.*`, `dofile`, `loadfile` | touch the default streams or read stdin, which is not the same on both sides |
+| `debug.debug()` | enters the interactive prompt (`lua_debug>`) and reads commands |
+| `math.atan` / `math.atan2` | **the same C function** (`{"atan", math_atan}` and `{"atan2", math_atan}`), so which name `findfield` reports depends on `lua_next` order — and the string-hash seed is randomised per process. Six reference runs: three `atan`, three `atan2`. The reference disagrees *with itself*; the name is normalised before comparing. |
+
+Values are never printed, only `type()`: `os.time()`, `os.date()` and
+`math.random()` differ on every run otherwise.
+
+#### The hook grid: masks × count × thread
+
+The existing hook tests were one mask per test; this one is the **full grid** —
+eight masks, three counts, four bodies, plus per-thread installation — because
+several semantics only show up in a cell:
+
+* `makemask` uses `strchr` and knows only `c` / `r` / `l`: `"x"` and `"CC"` are
+ **empty** masks (and it is case-sensitive). Reading only `"crl"` never reveals
+ this.
+* **`lua_sethook` turns the hook off entirely when `mask == 0`** — so
+ `debug.sethook(f, "")` installs *nothing* and `debug.gethook()` returns `nil`,
+ rather than a live hook that never fires. Likewise `debug.sethook(f, "x")`.
+* `count` joins `LUA_MASKCOUNT` only when `> 0`: `0` and `-1` produce no count
+ events, while `""` with count `1` is *count-only*.
+* hooks are **per thread**: a line hook on the main thread never fires inside a
+ coroutine, and `debug.sethook(co, …)` is that coroutine's alone.
+* `gethook`'s mask string **excludes** count (`unmakemask` emits only `c` / `r` /
+ `l`); count is the third return value.
+
+Two shape constraints matter as much as the assertions. Events are collected and
+printed at the very end — with hooks on, every `print` produces its own
+`call` / `return` pair and drowns the result. And the hook body spans several
+lines and calls a function: if `allowhook` re-entry protection were broken, this
+would be infinite recursion rather than a comparison failure.
+
+It came back clean.
+
+#### The integer / float subtype grid
+
+In 5.4 a "number" is one of two **subtypes**, and `math.type` can see the
+difference. This is the single most likely thing to get wrong when porting 5.4:
+a "everything is a double" implementation runs most scripts fine and diverges
+quietly in `//`, `%`, `string.format("%d")` and `math.tointeger`. So every door
+in the standard library that can hand back a number is swept, comparing only the
+subtype — values are never printed (`os.time` and `math.random` differ per run).
+
+156 lines, in two halves: everyday shapes (the math library's
+"integer in, integer out; float in, float out", four ways of writing `tonumber`,
+the bitwise operators, every `string.unpack` width, `io`'s `seek`, `os.date`'s
+fields) and the boundaries (`maxinteger + 1`, `mininteger // -1`,
+`math.fmod(1, 0)` and `1 // 0` **raise** rather than returning nan,
+`math.floor(-0.0)` is an integer, `math.tointeger(2^63)` is `nil`).
+
+Every line is wrapped in `pcall` — that is how the two raising cases were found.
+Without it the script dies at the first one and the remaining hundred lines are
+never compared, while the test still looks green.
+
+The first version of this sweep had a bug worth recording: merging the two probe
+scripts left the second half calling the *first* half's helper, which took a
+value instead of a thunk — so 72 of 156 lines silently read `tag nil` and the
+test was green anyway. **A matrix whose lines are all `nil` proves nothing**;
+check the reference output for uniformly-empty lines before believing a clean run.
+
+#### The subprocess sweep: `luaL_pushfail` is `nil`, not `false`
+
+`io.popen`, `io.tmpfile` and `setvbuf` were the thinnest part of the `io` library —
+one or two cases each. Four scripts (read/write pipes, mode validation, `tmpfile`,
+every `setvbuf` mode) widened that, and the very first run bit:
+
+```
+e3 true nil exit 3 <- reference
+e3 true false exit 3 <- ours
+```
+
+`luaL_execresult` pushes `luaL_pushfail` when the child exited non-zero, and in
+5.4 that macro is **literally `lua_pushnil`**
+(`lauxlib.h: #define luaL_pushfail(L) lua_pushnil(L)`) — it has been since 5.4
+split "fail" from `false`. This was invisible for the whole project because
+`os.execute` has its own copy of the logic and got it right; only `io.popen`'s
+teardown goes through the shared `execResult`, so `popen` was the only door that
+could show it. **Two implementations of the same helper means the bug hides in
+the one nobody tested.**
+
+Three things here are measured as uncomparable, and each was measured rather than
+assumed away:
+
+* **A child's stdout / stderr has to be redirected inside the command.** The
+ inherited descriptors do not end up in this implementation's capture buffer (a
+ Java child inherits the JVM's real stderr), so the two sides cannot line up.
+ Hence `cat > /dev/null` and `2>/dev/null`, not `cat`.
+* **Closing an undrained pipe is a race.** The reference gets `nil, "signal", 13`
+ (`echo` died of SIGPIPE); we get `true, "exit", 0`. Which one you get depends on
+ whether the child finished writing first — it is not stable run to run and must
+ not be compared.
+* **`setvbuf` with a negative or huge size is libc-defined.** C hands the size to
+ libc as a `size_t` and whether the buffer can be allocated is libc's call: on
+ the same machine `2^31` fails while `2^40` succeeds, and `"no"` with `-1`
+ succeeds while `"line"` with `-1` fails. Not modelable on the Java side.
+
+#### The success path: a third matrix, over every library function
+
+The two earlier sweeps over *every* library function asked two questions: does the
+function **exist** (the symbol audit), and what does it do when you hand it the
+**wrong** arguments (the arity and wrong-type sweeps). Both go through
+`luaL_check*` / `luaL_opt*` — the checking branch. Neither touches the code that
+runs *after* the checks pass. A `string.rep` that computes the wrong thing would
+have stayed green under both.
+
+So there is now a third matrix: hand every library function a set of **valid**
+arguments and compare the return **values**, not their types. 209 lines in three
+scripts — `string` and `math` (68), `table`/`utf8`/`os`/`coroutine` (44),
+`io`/`debug`/the globals (97).
+
+Values are described by a `d()` that keeps the comparison deterministic without
+weakening it: a number carries its `math.type` subtype, a string carries its
+length, a table is expanded two levels with keys sorted (so neither `pairs` order
+nor an address can leak in). Only a handful of things compare type instead of
+value — `os.time`, `os.clock`, `collectgarbage("count")`, and `tostring` of a
+table.
+
+Two results worth having: seeded `math.random` matches (the xoshiro256\*\*
+sequence is right, not just its range), and `getmetatable("")` dumps the entire
+string metatable in one line — `__index` and all seventeen methods.
+
+**The matrix's sensitivity was measured, not assumed.** Breaking one line of
+`string.upper` (shifting `'a'…'z'` to `'B'…'Z'`) turns the run red at
+`s.upper [2] q(3)[Abc]`. A matrix that cannot fail is worse than no matrix, and
+the only way to know is to break something and watch it go red.
+
+209 lines, byte-identical, first run. Which is also the suspicious outcome — see
+the note above about matrices whose lines are uniformly `nil`. Here 205 of 209
+lines carry real values, so the matrix is not vacuous.
+
+#### The `io.open` mode matrix, and three things stdio decides
+
+`l_checkmode` accepts `[rwa]%+?b*` — and the tail is `strspn(mode, "b") == strlen(mode)`,
+not "at most one `b`". So `"rbb"` is valid while `"rb+"` is not, and `"r b"`, `"br"`,
+`"R"`, `"r\t"` all fail. 34 mode strings × (read / seek / write / position / close /
+resulting file content) — 79 lines.
+
+It found three divergences. All three are **stdio's** call, not Lua's, and all three
+were measured before being set aside:
+
+1. **On an update stream, writing straight after reading** — with no intervening
+ positioning call, which ISO C leaves undefined — lands wherever C's read-ahead
+ buffer left the physical offset. Measured: a 12-byte file writes at 13 (EOF); a
+ 6002-byte file writes at 4097 (`BUFSIZ` + 1); but `read(5000)` writes at exactly
+ 5001, because a large read bypasses the buffer. The matrix now puts an explicit
+ `seek` before every write, which sidesteps the UB path — and on the well-defined
+ path (`read → seek → write`) the two agree byte for byte.
+2. **Where the position lands after writing to an append file.** `seek("set", 0)`
+ then write one byte: the reference reports **1** (`base + n`), we report 13
+ (`EOF + n` — ours is the POSIX answer). Measuring further showed it is buffer-
+ defined too: `n ≤ 4095` gives `base + n`, `n ≥ 4096` gives `EOF + n`. The matrix
+ flushes before reading the position, which collapses it onto the one POSIX-
+ defined answer so the two sides are comparable. **The bytes written are identical
+ either way** — only what `seek("cur", 0)` reports differs.
+3. **`seek` on a standard stream** depends on whether the real descriptor is
+ seekable: 0 when redirected to a file or `/dev/null`, `"Illegal seek"` on a pipe
+ or a tty. This implementation has no access to that descriptor, so it is not
+ compared.
+
+Two of the three are the same shape as the `setvbuf` sizes: C hands the decision to
+libc and there is nothing sensible to model. The difference is that here the
+*observable* is not just an errno but a file offset — which is why they are written
+down rather than merely excluded.
+
+#### `require` against a real filesystem
+
+The `package` gate used to exercise only the pure-Lua half of the searcher chain,
+by populating `package.preload` in memory. `PackageLib` was still the second-
+thinnest class by coverage, so there is now a matrix that writes **real modules to
+disk** and makes `require` find them: 57 lines covering what a module may return
+(nil, false, true, a number, nothing, multiple values), a module that raises, one
+with a syntax error, one that sets `package.loaded` itself, dotted names turning
+into directories, the cache on a second `require`, a circular `require` (the
+reference gives `C stack overflow`, not a sentinel), `package.loaded` pre-set to a
+value / `false` / `nil`, `package.preload` given a function / a function returning
+nil / a raising function / a non-function, `searchpath`'s `sep` and `rep`, and the
+`searchers` chain — its length, each entry called directly, and the whole chain
+replaced.
+
+One thing it pins down that would otherwise rot quietly: every
+`module 'X' not found` message prints the **entire** candidate list, so the default
+`package.path` has to match the reference binary's compiled-in `LUA_PATH_DEFAULT`
+character for character.
+
+57 lines, byte-identical on the first run — so, again, sensitivity measured rather
+than assumed: changing `searchpath`'s default `sep` from `"."` to `"-"` turns it
+red at `sp_dots`.
+
+#### Reading the coverage report line by line
+
+Picking a target by class-level miss ratio only goes so far — after the mode and
+`require` matrices the top holes barely moved, because a class's uncovered
+instructions are mostly `catch (IOException e)` handlers that no Lua program can
+reach. So the next pass went one level down: open
+`target/site/jacoco//IoLib.java.html` (the XML report has no line detail, the
+HTML does), collect the `class="nc"` line numbers, and ask of each one **"can a Lua
+script reach this?"**
+
+Of 63 uncovered lines in `IoLib`, most are genuinely unreachable error handling.
+The rest were reachable behaviours that had simply never been run — 39 lines:
+
+* `tostring` of a closed handle is `"file (closed)"` — *not* `io.type`'s
+ `"closed file"`; two different strings, and only one of them was being tested.
+* `io.lines()` with **no argument**, and with an explicit `nil` — these go through
+ the registry's `IO_INPUT`, a different path from `io.lines(filename)`.
+* Every shape of `f:read("n")`: exponents (`1e5`, and `1e999` giving `inf`), hex
+ (`0x10`, `0x1p4`, `0x.8p1`), a leading zero (`0777` — decimal, not octal),
+ truncated prefixes (`0x`, `1e` give nil), and an over-long digit string (nil).
+* Closing the file *during* `lines` iteration: the iterator then raises
+ `"file is already closed"`.
+* A large write (5000 bytes), which takes the branch that bypasses the write
+ buffer and goes straight to the file.
+
+All 39 came back byte-identical — those branches were **correct but never
+executed**. Their value is as a regression net: change `read("n")`'s prefix
+handling or the write-buffer threshold and this goes red immediately.
+
+#### The directory bug: one POSIX rule, five wrong answers
+
+Reading `PackageLib`'s uncovered lines and asking "can a Lua script reach this?"
+led to `searcher_C`'s fallback and, from there, to a module name that collides
+with a directory. That turned out to be **one rule mis-modelled in five places**:
+
+POSIX lets `open(dir, O_RDONLY)` succeed. `fopen(dir, "r")` therefore succeeds;
+`fopen(dir, "w")` gives `EISDIR`; and the first `fgetc` / `fread` fails with
+`EISDIR`. The JVM refuses to open a directory at all, so this implementation got
+all five entry points wrong in one consistent way:
+
+| | reference | before |
+| --- | --- | --- |
+| `loadfile(dir)` | `nil, "cannot read
: No such file or directory", 2` |
+| `io.open(dir, "w")` | `nil, "
: Is a directory", 21` | `nil, "
: No such file or directory", 2` |
+| `package.searchpath` | finds it (returns the path) | `nil, "no file '
'"` |
+| `require(name)` | `error loading module … cannot read …` | `module '…' not found` |
+
+The fix is one idea applied at four sites: `openInto` lets a read-only open of a
+directory through (and reports `EISDIR` for anything writable), `LuaFile.getc` /
+`read` fail with 21, `seek` / `tell` treat it as a zero-length file (a directory
+descriptor *is* seekable — `f:seek("set", 5)` returns 5, and `f:seek("set", -1)`
+is still `EINVAL`), `PackageLib.readable` accepts directories, and `loadfile` /
+`searcher_Lua` take the `cannot read` branch instead of `cannot open`.
+
+18 lines, byte-identical after the fix. Two things about how this was found are
+worth more than the bug:
+
+* The README previously said directories were "fine and must not be fixed" —
+ written from reading `lauxlib.c`, not from running the reference binary. It was
+ wrong. Measure, then write.
+* The same sentence in the *code* comment (`BaseLib`) had asserted the behaviour
+ was already correct. A comment agreeing with the code is not evidence.
+
+#### The errno shape table: `os.remove` / `os.rename`
+
+`LuaAux` still had 15 uncovered lines, and most of them were `errnoOf` / `strError`
+branches that no Lua script had ever reached — `ENOTEMPTY`, `EACCES`, and friends.
+22 lines aimed at them found **five** divergences, four of them real bugs:
+
+* **`ENOTEMPTY`'s number is chosen by the OS, not by libc**: 39 on Linux, 66 on
+ macOS/BSD. It was hard-coded to 39, so `os.remove(nonempty_dir)` had the right
+ *message* and a number 27 off. It now comes from `os.name`.
+* **`strError` must know both numbers.** Fixing only the number turned the line
+ into "errno 66 correct, message `Input/output error`" — the numeric constant and
+ its text have to move together.
+* **`os.remove("")`** gave `EIO` where C gives `ENOENT` — the same "C and Java
+ judge a path differently" corner as `loadfile("")`.
+* **`os.rename`'s whole shape table was wrong.** File → existing directory is
+ `EISDIR` (21); directory → existing file is `ENOTDIR` (20); directory → *empty*
+ directory **succeeds** (it replaces); file → existing file also replaces. Every
+ "target already exists" case was reporting `EEXIST`, because the code called
+ `Files.move` without `REPLACE_EXISTING`.
+
+(`os.remove("/etc")` used to be skipped as machine-dependent — it is `EPERM`
+here because of SIP. It is no longer skipped: see *The errno shape table* below,
+where the whole class of cases became **derived** instead of hard-coded, and
+therefore portable.)
+
+The general lesson is the one from the directory bug again: **"the value differs
+per platform" is a reason to derive it from the platform, not to leave the case
+untested** — skipping it is what let a plain wrong constant survive.
+
+### Byte-by-byte mutation: naming every `error()` in `lundump.c`
+
+`Undump` had 11 branches no script had ever reached — truncated chunk, integer
+overflow, bad format for a constant string, version / format / size mismatch.
+Hand-picked bad samples only cover the positions you think of, so this sweep is
+**byte-by-byte mutation** instead: every truncation length, every byte position
+mutated to each of four values, varint-limit injections, and trailing garbage,
+run over the plain dump, the stripped dump, and a dump with upvalues and nested
+protos. 865 lines, 21 distinct outcomes, 542 lines that are not `ok`. All 11
+branches come out, two of them in places no hand-picked sample would have hit.
+
+Two traps first, because either one silently voids the whole sweep:
+
+* **`string.dump` output carries the source name, and the source name defaults to
+ the script path.** The reference runs `/tmp/x.lua`, ours runs
+ `target/rt/case.lua`, so the dumps differed by 7 bytes and every offset shifted
+ — the first run showed 40 "divergences" that were all this one cause. The probe
+ pins the chunk name to `"=X"`, after which the two dumps are byte-identical.
+* **The varint-limit injection has to land after `nupvals`.** The header is 31
+ bytes, not 30; byte 32 is the upvalue count.
+
+The sweep found exactly one real divergence, and it was **a branch we invented**:
+
+* **`loadSize`'s `v > Integer.MAX_VALUE → integer overflow` does not exist in C.**
+ The old comment asserted that "C would allocate and raise `LUA_ERRMEM`" — a
+ conclusion read off the C source, and measured to be wrong: with a source-name
+ length of `2^35-1` (varint `7f 7f 7f 7f ff`) the reference reports
+ `truncated chunk`, not a memory error. malloc of 34GB succeeds on 64-bit, so C
+ goes ahead and reads those 34GB and hits end-of-stream. The branch is gone; long
+ strings are now **accumulated while reading** — how much is buffered depends on
+ how many bytes the stream actually has, not on how many it claims, and hitting
+ the end still reports `truncated chunk`.
+
+The reverse check afterwards — swapping `loadUnsigned`'s `limit >>>= 7` for the
+`Long.MAX_VALUE >>> 7` its own comment warns about — **turned up a second defect
+in passing**:
+
+* **`loadCode`'s `new byte[n * 4]` can overflow to a negative number** and throw
+ `NegativeArraySizeException` — **a Java exception, which `pcall` cannot catch**,
+ so one malformed chunk takes down the host process. It also paid twice: C
+ allocates `n` instructions, this allocated `n` plus another `4n` bytes, i.e.
+ double whatever an untrusted input asked for. Reading instruction by instruction
+ from a reused buffer removes the overflow and allocates only `n`. The same
+ sabotage re-run afterwards: **no crash**, and 13 red lines — the matrix is
+ sensitive to errors this small.
+
+The lesson is the standing one: **a comment agreeing with the C source is not
+evidence.** The more plausible that comment looked, the easier it was to read the
+mismatch as a deliberate deviation — and a branch that exists nowhere in C stayed
+quiet until a machine checked it.
+
+### JaCoCo can lie: the line that calls an always-throwing method
+
+Picking targets from uncovered lines has a trap, and this is where it surfaced:
+**JaCoCo marks the line that calls an always-throwing method as not covered.**
+The instruction after the call — the `pop` that discards the return value — is
+never reached, so the whole line is recorded as `nc`.
+
+Measured: `Undump`'s ten `error()` branches fired **848 times** during the suite
+(counted by adding a print inside `error()`), every line *inside* `error()` is
+`fc`, and **all nine call sites are `nc`**. Following the report would have meant
+writing more cases for code that was already covered.
+
+The test: **the line is an `xxxError(...)` call or starts with `throw`, and the
+method it calls is itself covered** — that is a fake gap. Filtering on it makes
+`Codegen`'s 15 lines, `Parser`'s 21 and `Undump`'s 10 **disappear entirely**;
+what is left is real.
+
+### The table-key matrix: `rawequal(math.maxinteger, math.maxinteger + 1.0)`
+
+Of the real gaps, `LuaTable` had 16 lines, all paths that only run for narrow key
+ranges: `hashFloat`'s `-0.0` normalisation, `landsInArray`'s float branch, rehash
+when the hash part needs more slots. A handful of hand-picked keys never reaches
+them, so this is a **key × key** square: 20 keys each written once, then read back
+with all 20, plus 2048 integers on each side of `math.maxinteger` swept by step.
+
+It turned up exactly one real divergence, and it is **not in `LuaTable`**:
+
+* **`rawequal(math.maxinteger, math.maxinteger + 1.0)` should be `false`.** The
+ two numbers are genuinely different (`math.maxinteger` is 2^63−1, the right side
+ is the float 2^63), but `(double)(2^63−1)` rounds to `2^63`, so "cast both to
+ double and compare" calls them equal. Lua 5.4 compares two numbers of
+ *different variants* the other way: convert **both** with F2Ieq and compare
+ integers; if the float side does not convert, they are not equal. The fix is in
+ `LuaValue.numberEqual`, which backs `==`, `rawequal`, **and the table's key
+ comparison** — so `t[math.maxinteger]` and `t[2^63]` were previously the same
+ entry.
+ The duplicate `floatToIntExact` in `LuaTable` is now folded into
+ `LuaValue`, so the two copies of that rule cannot drift apart.
+
+The two rules only diverge over roughly the thousand integers before
+`math.maxinteger`, which is why the stepped sweep at the end of the script
+matters: **without it the fix is worth 6 lines; with it, reverting turns 77 red.**
+
+`next`'s **order** depends on the hash function and need not match, so key sets
+are always sorted before printing — the set is compared, not the order.
+
+### The `debug` frame-shape matrix: the hook frame is at level 3
+
+After filtering out the fake gaps, `DebugLib` still had 22 lines, all paths that
+only run when the stack has a particular shape: `currentLine` returning `-1` for
+non-Lua frames, `funcNameFromCallInfo`'s `ci.hooked` branch, the
+`getinfo`-with-a-*function* (rather than a level) path, and `traceback`'s
+`(...tail calls...)`. Two hand-picked examples never reach them, so this is a
+frame-shape matrix: top level, Lua frames, C frames (looking outward from
+`table.sort` and `pcall` callbacks), inside and outside a coroutine, a tail-call
+chain, and a long function under a line hook. 106 lines.
+
+Two things worth recording:
+
+* **The hook frame is at level 3.** Inside a hook, level 1 is the `pcall` wrapping
+ the call, level 2 is the printing helper, and **the hook's own frame is level
+ 3**. Stop at level 2 and the `namewhat = "hook"` branch is never reached — and
+ on the report it looks exactly like every other uncovered line.
+* **One thing is not compared**: the `"(C temporary)"` of the outermost C frame is
+ whatever the host pushed when calling `pmain`. The reference pushes
+ `(argc, argv)`, so it reads back `2` and a userdata; this driver pushes the
+ script text. **That is the host's calling convention, not Lua semantics**, so
+ the script only goes up to level 2.
+
+Result: 106 lines match byte for byte, no real divergence, and three never-taken
+paths are now covered — `getupvalue` on a C closure, `getinfo` with a function
+plus a thread, and `traceback`'s tail-call marker. What remains uncovered is
+defensive (`HOOKKEY` missing, hook not a function) plus one line that **only a
+host can reach**: `debug.gethook`'s `"external hook"` — `sethook` from Lua always
+installs `HOOKF`, so no script gets there.
+
+### The coroutine × C-boundary matrix: `xpcall`'s message handler cannot yield
+
+Every entry point that C drives from inside a callback has its own `nny`
+(C's `nyci`) bookkeeping, and a single missing `+1` is invisible until something
+yields at exactly that spot. So this is a matrix over **where** the yield
+happens: `pcall`, `xpcall`, **`xpcall`'s message handler**, `table.sort`'s
+comparator, `string.gsub`'s replacement function, `load`'s reader, eight
+metamethods (including `__close`), all four kinds of generic-`for` iterator, a
+debug hook, `coroutine.wrap`, a nested coroutine, `resume` on a dead and on an
+errored coroutine, and a suspended coroutine resuming *itself*. Each row also
+asks `coroutine.isyieldable()` separately, so "may I" and "what happens if I do"
+are two measured answers rather than one. 67 lines.
+
+It found one real divergence, and it is worth stating precisely because the
+wrong behaviour looks *more* capable, not less:
+
+```
+run xpcall_msgh true false error in error handling <- reference
+ status xpcall_msgh dead
+run xpcall_msgh true in msgh <- this implementation
+ status xpcall_msgh suspended
+```
+
+`LuaState.applyErrMsg` was calling the handler with the yieldable `pcall`;
+C uses `luaD_callnoyield`. The reason matters more than the symptom: being able
+to yield inside the error-handling continuation means an
+**"error half-delivered"** intermediate state can be suspended — and C has no
+such state. Fixed with `callNoYield` plus a `catch (LuaError)` that stops
+backstopping the handler's own failure, which is what produces
+`"error in error handling"`.
+
+Two more things this matrix pinned down:
+
+* **`pairs` order over string keys is randomly seeded, so it cannot be
+ compared.** String keys go through `luaS_hash`, whose seed comes from
+ `lstate.c: luai_makeseed` — `time(NULL)` mixed with three ASLR addresses.
+ Running the same script against the reference five times gave `in pairs:a`,
+ `a`, `b`, `a`, `b`. Any probe that prints raw key order is a
+ **fake-red generator**; this one drains the coroutine to death and prints the
+ collected yields **sorted** instead. (This implementation has no seed at all,
+ so its order is fixed and deterministic — a deliberate, safe asymmetry: the
+ reference's own order is not observable either, since nothing can depend on
+ a value that changes every run.)
+* **`load`'s reader must terminate.** A reader that never returns `nil` makes
+ `load` read forever — the first version of this probe **hung the reference
+ binary** and had to be `pkill`ed.
+
+Reverse verification: swapping `callNoYield` back to `call` turns exactly two
+lines red, and the test fails.
+
+### The to-be-closed matrix: errors raised inside `__close` also reach the message handler
+
+`` is the only new syntax in 5.4 and it has the most edges, so this is a
+matrix over **how the block is left** (falling off the end, nested, `return`,
+`break`, `goto`, `error` with a string and with a table) crossed with **what
+`__close` itself does** (log, raise, raise a non-string, return several values,
+`yield`, be a number, be a callable table, be absent), then crossed again with
+`pcall` / `xpcall` / coroutines / `coroutine.close`. 56 lines.
+
+It found two divergences with one root cause: **an error raised inside `__close`
+must go through the message handler too.**
+
+```
+msgh orig | close a err=H | msgh E:a | xpcall -> false H <- reference
+msgh orig | close a err=H | xpcall -> false E:a <- this implementation
+```
+
+`luaD_pcall` restores `L->errfunc` on its **last** line, and
+`luaD_closeprotected` runs *before* that line — so the handler is still installed
+for the whole time `__close` runs, and `error()` inside it reaches
+`luaG_errormsg` just like any other error. The practical consequence is easy to
+miss: **the final result is the handler's return value, not `__close`'s error.**
+The same fix produces `"error in error handling"` in the case where the handler
+itself raises — that string comes from the `__close` error making a second pass
+through a handler that is already failing.
+
+The second divergence is the coroutine side of the same rule:
+`finishpcallk` restores `L->errfunc` *after* `luaF_close`, so the handler must
+run **before** the closing methods, not after. Getting this backwards shows up as
+`close a err=after | msgh h2 after` instead of `msgh h2 after | close a err=H-h2`
+— the closing method sees the raw, unhandled error.
+
+Moving that call earlier exposed a second, independent trap: the handler must be
+called **before** `top` is pulled back to `fIdx + 1`. `luaG_errormsg` pushes its
+call frame at the *current* top of the stack, where the protected function's
+locals still live; pull `top` back first and it starts writing at `fIdx + 2`,
+which is exactly the first local slot — the `` variable — so `__close`
+gets overwritten with the handler itself and the next call reports
+`attempt to call a nil value`.
+
+One combination is deliberately **not** in the matrix: a message handler that
+raises. `luaG_errormsg` recurses there until the C stack overflows, producing
+several hundred lines whose count varies with stack depth. The matrix pins the
+*result* only.
+
+### The debug-hook matrix: whether a hook error is catchable depends on where the hook was set
+
+Hooks are the one place where this implementation interrupts the VM's normal
+flow with a Java exception, so events are emitted from three different points
+(`precall`, `poscall`, the instruction loop) with `allowhook`, `nCcalls` and
+`ci` each in a different state. This is a matrix over the eight mask
+combinations (`c` / `r` / `l` and their unions) crossed with `count`
+(0, 1, 2, 3, 5), crossed with frame kind (Lua, C, tail call, coroutine), crossed
+with what the hook itself does (disable itself, swap itself, raise, raise a
+non-string, `yield`, read `debug.gethook`). 56 comparable lines.
+
+Result: byte for byte identical, **no real divergence**. It is still worth
+keeping, because it is measured to be sensitive — renaming the `"tail call"`
+event to `"tailcall"` turns 14 lines red.
+
+The interesting thing it pinned down is not a bug but a trap in writing such
+probes:
+
+* **Whether a hook error can be caught depends on where `sethook` was called.**
+ The very next `call` event after `sethook` is for `pcall` itself — the `c`
+ mask counts C functions — and that fires *before* `pcall` has established its
+ protected region, so the error escapes and kills the script. The reference
+ reports `lua: hk1` with `[C]: in function 'pcall'` in the traceback and stops
+ there. To test *catching*, `sethook` has to run **inside** the `pcall`; the
+ escaping variant can only go **last** in the script, or everything after it is
+ silently uncompared while the test stays green.
+* **Reading the stack from inside a hook needs level 3.** Level 2 is the
+ printing helper; level 3 is the frame with `namewhat = "hook"`.
+
+### The type-metatable matrix: `setmetatable`'s first argument must be a table
+
+`lua_setmetatable` dispatches three ways: tables and userdata store the
+metatable on the object, everything else installs it in `global_State`'s `mt[]`
+where **the whole type shares one**. This matrix walks all eight types through
+`debug.setmetatable` / `debug.getmetatable`. 48 lines, byte for byte identical,
+and it is sensitive — deleting one line of the userdata branch turns 6 lines red.
+
+The reason that branch had never been covered is worth stating: **`setmetatable`
+from Lua requires its first argument to be a table** —
+`lbaselib.c` starts with `luaL_checktype(L, 1, LUA_TTABLE)` — so userdata can
+only be given a metatable through `debug.setmetatable`, and no script in the
+suite ever did that. (The error message names the type via `__name`:
+`bad argument #1 to 'setmetatable' (table expected, got FILE*)`.)
+
+Two things it pins down:
+
+* **Only tables and full userdata honour `__name`.** Give numbers, `nil`,
+ booleans, functions, threads or strings a type metatable with `__name` and the
+ runtime still says `number` / `nil` / `boolean` / … ; only the userdata becomes
+ `attempt to index a M_ud value`.
+* **The string type metatable *is* the string library.** Replace it and
+ `("x"):upper()` stops working, so the probe has to save and restore it — and
+ it must save it *before* the section that installs an empty metatable on every
+ type, or it saves the wrong table and the script dies halfway through.
+
+### The `package` matrix: the module name is a `const char *`
+
+`PackageLib` was the thinnest class by coverage (0.89), so this is a matrix over
+what `loadlib.c` actually does with a path: 18 template shapes (`""`, `";"`,
+`"a;"`, `";;a;;"`, …) crossed with `?` substitution, `sep` / `dirsep` corner
+cases, embedded NULs, argument checking, all four searchers called directly, and
+`loadlib`. 156 lines, byte for byte identical, and sensitive — dropping the
+`const char *` truncation turns 3 lines red.
+
+It found one real divergence and one wrong constant:
+
+* **`searcher_C` and `searcher_Croot` did not cut the module name at the first
+ NUL.** In C the name is `const char *name = luaL_checkstring(L, 1)`, so
+ `package.searchers[3]("a\0b")` searches for `a` — this implementation searched
+ for the whole string. Only reachable by calling a searcher directly (`require`
+ truncates first), which is why no earlier probe caught it.
+* **`loadlib`'s third return value has *two* constants, not one.** `ERRLIB`
+ (library will not open) gives `LIB_FAIL`, which is `"open"` on a dlopen
+ build and `"absent"` on the fallback build; `ERRFUNC` (library opened, symbol
+ missing) gives a hard-coded `"init"` — the fallback branch redefines only the
+ former. Verified against the reference with a real `.dylib`:
+ `package.loadlib(real, "luaopen_nosuch")` → `nil, , "init"`.
+ This implementation's `lookForFunc` always returns `ERRLIB`, so the branch is
+ unreachable here, but the constant is now the one C uses.
+
+Two things the matrix deliberately does **not** compare:
+
+* **`dlerror()` text.** It embeds an address; `dlsym(0x…, luaopen_x): symbol not
+ found` differs on every run, so the C-searcher section compares only the part
+ of the message before `\n\t`.
+* **`LUA_PATH` handling.** The `;;` → "insert the default path here" expansion is
+ real and easy to get wrong (only the *first* `;;` is replaced, and no extra
+ separators are added around the default). It cannot be driven from this harness —
+ the reference binary's environment is not controllable from JUnit — so it was
+ measured separately with `env LUA_PATH=…` over nine shapes
+ (`a;;b`, `;;`, `a;;`, `;;b`, `a;;b;;c`, `;;;`, `noMark`, empty, and
+ `LUA_PATH_5_4` taking priority): identical every time.
+
+**The matrix did not move coverage at all** — `PackageLib` is still 0.891 with
+the same 117 missed instructions afterwards. Those lines were already being
+*executed*; they were never *compared*. Coverage cannot see the difference, and
+"coverage went up" is not the test for whether a gap has been closed.
+
+### The `io` corner matrix: `f:read(0)`, big writes, `ESPIPE`, directories
+
+`IoLib.LuaFile` was the thinnest class left (0.80), and every line in it is a
+stdio rule rather than Lua semantics. This matrix covers `read(0)`, writes that
+bypass the 8192-byte write buffer, `seek` on a pipe, reading a directory, and
+`io.lines()` with no argument. 43 lines, byte for byte identical, and sensitive —
+changing the directory `errno` from `EISDIR` (21) to `EIO` (5) turns 3 lines red.
+
+What each group pins down:
+
+* **`f:read(0)` returns `""` before EOF and `nil` at EOF.** `fread` asked for zero
+ bytes returns zero, which counts as "read something" — so the empty string is a
+ real result, not an error. The mirror case (writing `""` to a read-only file
+ succeeds) was already pinned earlier; these are the two halves of one rule.
+* **Writes larger than the 8192-byte buffer skip the buffer entirely**, so
+ 10000 / 8192 / 8191 bytes are each written once and read back to check the
+ positions.
+* **`seek` on a pipe is `ESPIPE` (29)**, not a generic I/O error. Only
+ `io.popen` files are used for this — see the warning below.
+* **Directories: `fopen` succeeds, reading fails with `EISDIR` (21), and `seek`
+ follows yet another rule** — the position is computed normally and only a
+ negative target position is `EINVAL` (22).
+* **`io.lines()` with no argument reads the registry's default input.** Close it
+ and the error is `attempt to use a closed file`, *not* `no input file`: the
+ registry entry is still there, it is merely closed.
+
+**Never `seek` `io.stdout` / `io.stderr` in a differential probe.** When stdout is
+redirected to a *file*, that `seek("set", 0)` **succeeds**, the write pointer goes
+back to 0, and every later line overwrites the output from the start — the
+reference's output becomes a shuffled fragment of itself and looks exactly like a
+bug in the implementation. It only reports `ESPIPE` when stdout is a *pipe*. That
+depends on how the harness captures output, not on Lua.
+
+### The errno shape table: `errno` and `strerror` are inverse functions
+
+File operations do not fail along one axis ("exists or not"); the `errno` is
+decided by the **shape of the target**. `io.open("file/y")` is `ENOTDIR` (20),
+`io.open("/etc/hosts", "w")` is `EACCES` (13), `os.remove(".")` is `EINVAL` (22),
+`os.remove("/")` is `EISDIR` (21), `os.remove("/etc")` is `EPERM` (1) here. This
+implementation classified failures **by Java exception type** and fell back to
+`ENOENT` / `EIO` for everything else — so **five of those were wrong**.
+
+The fix is to *invert* rather than to enumerate a second table: `errno` and
+`strerror` are inverse functions, and Java's I/O exceptions carry the libc
+`strerror` text verbatim — `FileNotFoundException` as
+`" (Permission denied)"`, `FileSystemException.getReason()` directly as
+`"Not a directory"`. So `LuaAux.errnoOf` now looks the text up in
+`LuaAux.strError`'s table and gets the number back. 44 lines, byte for byte
+identical, and sensitive — disabling the lookup turns 9 lines red.
+
+**This is also what makes the matrix portable.** `os.remove("/etc")` is `EPERM`
+on macOS (SIP) and `ENOTEMPTY` on Linux — but *both* sides now derive their
+answer from the same libc, so they agree on each platform instead of one side
+carrying a platform-specific constant. That is the whole argument against
+"this value varies per platform, so skip it": skipping it is what let a plain
+wrong constant survive, and deriving it is what makes it testable.
+
+Two traps while writing the probe, both recorded in the test's javadoc:
+
+* **Fixtures go in a subdirectory and are deleted at the end** — not scattered
+ over the repository root.
+* **Never exercise `os.remove` against a real system file.** `os.remove("/etc/hosts")`
+ would *succeed* and delete it if the suite ever ran as root. The `EACCES` case
+ uses a `chmod 555` directory the test creates itself.
+
+#### The `utf8` sweep: one literal written twice, and one line nobody can reach
+
+`Utf8Lib` was at 0.930, and unlike `IoLib`/`PackageLib` its holes are pure logic —
+no platform behaviour, no `IOException` handlers. Reading the uncovered lines found
+a literal written **twice**: `open` built the `utf8.charpattern` byte array inline
+while `charPattern()` sat next to it with no callers at all. Two copies of the same
+constant is a drift that nothing can detect — the coverage report just shows both
+blocks `nc`. Merging them (and having the matrix actually read
+`utf8.charpattern`) puts the pattern under test.
+
+The 72-line matrix covers `utf8.char` at every boundary (0, `0x7F`, `0x80`,
+`0x7FF`, `0x800`, `0xFFFF`, `0x10000`, `0x10FFFF`, `0xD800`, `0x110000`,
+`MAXUTF`, `-1`, `math.mininteger` / `math.maxinteger`, non-integer, string), the
+**separate** `value out of range` branch that only arguments 2..n take, every
+`utf8.codepoint` bounds shape (`i > j` returning nothing at all, 0, past the end,
+negative, empty string, `lax` true/false, float / string / boolean arguments), and
+`charpattern` **used** rather than inspected — `gmatch` over `"a中b\u{1F600}"`
+yields 4 characters, over `"\xff\xfe"` yields 0.
+
+Byte-identical on the first run. `Utf8Lib` went 0.930 → 0.995 with one line left:
+`throw L.libError("string slice too long")`, which needs a string of at least two
+gigabytes. It is recorded as unreachable rather than chased.
+
+#### A syntax-error matrix: every error string in `lparser.c` and `llex.c`
+
+`Parser` was the second-biggest hole (169 uncovered instructions) and, unlike
+`IoLib`/`PackageLib`, its gaps are pure logic. Reading the uncovered lines showed
+they were almost entirely **error messages** — and every single one of them was
+`nc`, meaning no test had ever produced that message at all.
+
+So the matrix is not a hand-picked list; it is an **enumeration of error sites in
+the C source**: `error_expected` and both shapes of `check_match`, `errorlimit`
+for locals/upvalues/constructor items, `attempt to assign to const variable`,
+`jumpscopeerror` / `undefgoto` / duplicate labels, ` or '...' expected`,
+`function arguments expected`, `unexpected symbol`, `unknown attribute`,
+`multiple to-be-closed variables`, `cannot use '...' outside a vararg function`,
+`'=' or 'in' expected`, `control structure too long`, plus the whole lexical set
+(unfinished string / long string / long comment, malformed number, hexadecimal
+digit, `missing '{'`, UTF-8 value too large, `missing '}'`, decimal escape too
+large, invalid escape sequence, invalid long string delimiter). 172 lines.
+
+Three things it pins down that would otherwise rot:
+
+* **`` and `` are printed *without* quotes**, while `'end'` and `','`
+ are printed with them — `luaX_token2str` returns the bare string for
+ `token >= TK_EOS`. And the token after `near` goes through `txtToken`, which
+ uses the *raw source text* for strings and numbers, so the message can contain
+ nested quotes: `hexadecimal digit expected near ''\x''`.
+* **C stack overflow is *returned*, not raised.** Deep nesting trips
+ `luaE_incCstack`, but `luaD_protectedparser` catches it, so `load` hands back
+ `nil + "C stack overflow"`. Only `load`'s own argument check
+ (`bad argument #2 to 'load'`) actually raises. The probe wraps everything in
+ `pcall` so the two are distinguishable.
+* **`control structure too long` needs a loop body of more than `MAXARG_Bx`**
+ (262143) instructions, so that case is a generated 1 MB source — 0.02 s on the
+ reference, 0.6 s here, cheap enough to keep.
+
+Byte-identical on the first run, and sensitive: quoting every token name turns 11
+lines red, dropping `check_match`'s same-line shortcut turns 13 red, and
+`errorlimit`'s `"main function"` → `"main chunk"` turns 1 red.
+
+#### …and a hole in the coverage report, not in the code
+
+After installing it, `Parser`'s coverage barely moved — 169 → 167, while the
+uncovered-line list was **character for character identical**. That is not
+possible if 172 lines of new error paths really ran, and they did: instrumenting
+one of them with a `println` fires, and the byte-identical output can only have
+come from those `throw` sites.
+
+What gives it away is the line *above*:
+
+```html
+if (!lh.v.isVar()) {
+ throw ls.syntaxError("syntax error");
+```
+
+JaCoCo knows the guarding branch was taken in both directions, and the line
+number table proves `1315` has instructions of its own — yet the line reads
+`nc`. `Parser` has **19 `throw` lines and not one of them is `fc`** (17 `nc`,
+2 `pc`), while the rest of the project is 136 `fc` / 41 `nc`.
+
+The consequence for target selection: **an uncovered `throw` line is not
+evidence of an uncovered path.** Half of `Parser`'s apparent hole is this
+artifact, and chasing it would have burned an afternoon proving correct code
+correct. What actually tells you the path runs is the *comparison*, not the
+coverage number — which is the same lesson as "coverage going up ≠ the gap
+closed", from the other side.
+
+#### The `os.date` shape matrix: `*t` is a `strcmp`, not a prefix
+
+`OsLib` was at 0.952, and the existing `FORMAT` group only exercised the `!`
+(UTC) half. The new 337-line matrix adds the other half: **every conversion
+specifier in local time**, the same set at five timestamps (`0`, the 2038
+boundary, 1900, `-1`, and year 1), the error shapes for invalid specifiers, and
+the corners of `os.time` / `os.difftime` / `os.setlocale` / `os.getenv`.
+
+It found **one real bug**: `*t` was a *prefix* test, but C has
+`strcmp(s, "*t") == 0`. So `os.date("*t x")` should fall through to `strftime`,
+where `*`, `t`, the space and the `x` are all literals, and return the string
+`"*t x"` — this implementation returned a table instead.
+
+Fixing it surfaced a second detail, the same trap as everywhere else in this
+codebase: `strcmp` **stops at the first NUL**, so `os.date("*t\0x")` is *still*
+the table form in C. Comparing `f.length` against 2 is not enough; the string
+has to be truncated at `\0` first.
+
+Sensitivity: deleting the `E` / `O` two-character group from
+`LUA_STRFTIMEOPTIONS` turns 38 lines red, reverting `*t` to a prefix test turns
+1 red, and using `f.length` instead of the NUL-truncated length turns 2 red.
+
+Two things stay out of the matrix on purpose: **`!%z`** (macOS's `strftime`
+reports the *local* offset even under `gmtime`, glibc reports `+0000`; this
+implementation follows POSIX and can only agree with one of them) and
+**`os.date` / `os.time` with no argument** (wall clock).
+
+#### The `name` / `namewhat` grid: a function's name is reconstructed, not stored
+
+A function's `name` is not a property of the function — it is **reconstructed from
+the call site** (`funcnamefromcall` → `funcnamefromcode` → `getobjname`), by
+looking at whether the caller's instruction was `OP_GETFIELD`, `OP_GETTABUP`,
+`OP_GETUPVAL`, `OP_MOVE` or `OP_SELF` and then digging the name out of the
+constant table or a register. So the same function has a different `name` and
+`namewhat` at every syntactic position, which is exactly what a grid is for: 77
+lines over nine `namewhat` values — `global`, `local`, `field`, `method`,
+`upvalue`, `metamethod`, `for iterator`, `""`, and `nil`.
+
+Shapes that would not survive being guessed:
+
+* **`t[1]()` is named `"integer index"`, not `"?"`.** Integer keys are their own
+ branch in `getobjname`; only float and boolean keys fall through to `"?"`.
+* **`"?"` still reports `namewhat = "field"`** — the name may be unrecoverable
+ while the *calling convention* is not. The two are independent.
+* **`metamethod` names are the event name minus the `__`** (`index`, `newindex`,
+ `add`, `len`, `close`, …), while `__call` goes through a normal call and
+ reports `name="a1", namewhat="local"`.
+* When nothing is recoverable, `name` is `nil` but `namewhat` is the **empty
+ string**, not `nil`.
+
+First run byte-identical (bar the script path), so sensitivity was measured:
+`"integer index"` → `"?"` turns 5 lines red, and changing the case of
+`namewhat`'s `"field"` turns 1 red.
+
+### `__gc` is deliberately not chased further
+
+There is no `lgc.c` here; `__gc` is reconstructed with `Cleaner`, so **when** it
+runs depends on the JVM's collection timing, while the reference's timing depends
+on its own incremental collector. Neither is guaranteed, so byte-for-byte
+comparison is not stable in principle — even the "add `__gc` after setting the
+metatable" rule measured differently for tables and for userdata. The handful of
+reproducible shapes already in `GcTest` (runs once, errors are swallowed, adding
+`__gc` late does not count) is the whole target; no more matrices are built for
+it, and an apparent divergence there is not evidence of a bug.
+
+Nothing in the language or the ten standard libraries, and every upstream file
+whose output is deterministic now matches byte for byte. The remaining work is
+verification breadth: more differential scripts and a wider fuzz corpus. The only
+upstream files still off the list are `all.lua` / `main.lua` (the driver: it needs
+the `T` module built from `ltests.c` and writes `time.txt`), `attrib.lua` /
+`files.lua` / `big.lua` (they need the C modules in `libs/` and the globals
+`all.lua` sets up — the reference binary fails on them standalone too),
+`heavy.lua` (minutes), and `bwcoercion.lua` (no output standalone).
+
+### The library-argument grid: every function, every wrong argument
+
+`bad argument #1 to 'string.rep' (string expected, got boolean)` is the message
+real projects hit constantly, and every branch that produces it lives in
+`lauxlib.c`. The grid enumerates the functions **at runtime** (`_G` plus the nine
+library tables) rather than from a hand-written list — a hand-written list cannot
+miss a function silently, and "silently" is exactly what happened when `warn` was
+unimplemented for a long stretch with 828 green tests. 108 functions × `{0, 1, 2}`
+arguments × 10 values = **2396 lines**, all byte-identical.
+
+Three judgments it pins down:
+
+* **A missing argument reports `no value`, not `nil`.** `luaL_typename` on an
+ out-of-range index gives `no value`. Breaking it turns **212** lines red.
+* **`lua_type` on an out-of-range index is `LUA_TNONE`**, not `LUA_TNIL`, so a
+ parameter declared "nil or table" does not silently accept absence:
+ `setmetatable({})` must report `nil or table expected, got no value`. Only one
+ line in the grid covers it — which is one line more than before.
+* **Method calls subtract one from the argument index** (`arg--` in
+ `luaL_argerror`), and if that lands on 0 the message changes to
+ `calling 'x' on bad self (...)`. A direct call never reaches either branch, so
+ the grid adds a second, **literal** block of `(""):f(v)` / `(1):f(v)` calls
+ (377 lines) — `OP_SELF` only comes from a syntactic method call, it cannot be
+ assembled at runtime. Sensitivity 75 and 22.
+
+Two normalisations are deliberate: `math.atan` and `math.atan2` are the same C
+function and `findfield` picks one based on the string-hash seed, which changes
+every run; addresses become `PTR`. The functions on the blacklist are excluded
+for side effects, not for difficulty — `print` and `io.write` write to stdout,
+`os.date` / `os.time` / `os.clock` read the clock, `math.random` is random,
+`io.read` / `io.lines` read stdin, `debug.sethook` leaves a hook installed,
+`debug.setmetatable` mutates a global type metatable, and `collectgarbage`'s
+`step` / `count` are the documented deviations.
+
+There is a third layer on top of the same grid: **what a valid call returns**. The
+first version printed `OK` on success and threw the return values away, which
+hides an entire class of bug — return-value *type*. `math.abs(-1)` returns the
+integer `1`, not the float `1.0`, and `tostring` prints those as `1` and `1.0`.
+Routing `math.abs`'s integer branch through the float path leaves the
+argument-error grid **completely green** and turns 6 lines red here; conversely,
+breaking `math.type`'s return value turns 2 lines red here and none there. Of the
+2729 output lines, 959 are successful calls covering 139 distinct return shapes.
+Two functions are excluded because of their *return values*: `os.tmpname` is
+random, and `debug.traceback`'s return value contains `stack traceback:`, which
+the harness would cut at — silently skipping everything after it.
+
+### `debug.debug`: the one function that reads stdin
+
+`ldblib.c: db_debug` is a loop, and all three of its rules were wrong here before
+this grid existed:
+
+* it writes `"lua_debug> "` to **stderr before every read** (no newline). This was
+ missing entirely, so the reference's output had eleven bytes we never produced;
+* it **loops** until EOF or a line that is exactly `"cont\n"`, executing each line
+ as a chunk named `=(debug command)`. The Java version read one line and
+ returned;
+* a command that fails prints `luaL_tolstring(L, -1)` to stderr and the loop
+ **continues**.
+
+Testing any of this needs stdin, which the differential harness did not have — it
+redirects the reference's stdin to `/dev/null` and leaves `LuaState#in` as an
+empty stream, so only the "print one prompt, then EOF" shape was reachable.
+`Diff.assertMatchesWithStdin` feeds the same bytes to both sides, which brings the
+loop body, the error branch, `cont`, a line with no trailing newline, and the
+250-byte `fgets` buffer split (a long line is chopped into several chunks, each
+executed separately) into CI. All six cases now match byte for byte.
+
+One thing worth recording about ordering: C's `print` goes through
+`lua_writeline`, which calls `fflush(stdout)`, so stdout and stderr interleave in
+program order even when both are captured into one pipe — not "all stdout at
+exit".
+
+### The `varinfo` matrix: the name in an error message is reconstructed, not recorded
+
+`attempt to index a nil value (local 'x')` — the parenthesised tail comes from
+`ldebug.c: varinfo`, and none of it is stored anywhere. `varinfo` first asks "is
+this value an upvalue of the running closure?", then "which register holds it?",
+then hands that register to `getobjname`, which guesses from **the last
+instruction that wrote it**. The grid (**112 lines**) pins every kind: `local`,
+`upvalue`, `constant`, `global`, `field`, `method`, `(for iterator 'for
+iterator')`, plus the two failure shapes `field 'integer index'` (`OP_GETI`, so
+the kind is `field` and the *name* is the literal string `integer index`) and
+`field '?'` (a key it cannot recover). The empty tail is part of the answer too:
+`attempt to call a number value` must **not** grow a `(constant '42')`, because
+`kname` names *string* constants only — two rows pin that.
+
+The valuable half of this matrix is what it found by **not** being sensitive. The
+first 21 cases were written on the intuition "a conditional assignment makes the
+name disappear": `local x = nil; if c then x = nil end; return x.y`. All 21 answer
+`(local 'x')`, and rewriting `filterpc` as an unconditional `return pc` left all
+21 green. The reason is in `basicgetobjname`: it calls `luaF_getlocalname`
+**first**, and any register that is a live local at the failing pc returns
+`"local"` on the spot — `findsetreg`, its jump-target filter and all, is never
+entered. Symbolic execution only ever speaks about **temporaries**.
+
+What does reach it is `and` / `or`: in `(c and t.a or t.b).z` both branches write
+the *same* temporary and both writes sit before the jump target, so the last write
+is judged "may not have executed" and discarded, and the tail collapses to the
+empty string. Adding those turned the very same sabotage from 0 red lines into
+**234**.
+
+Sensitivity, measured by breaking one line and reverting: `filterpc` 234,
+`instack` 435, `upvalname`'s `"?"` 211, `kname` string-only 131, `OP_MOVE`'s
+`b < a` 22, `OP_LOADNIL`'s register range 10, `"integer index"` 3, `isenv`'s
+`global` 3, `method` 2.
+
+One branch is recorded as unreachable rather than quietly assumed covered.
+Instrumenting `findsetreg` over the grid and the 33 upstream files shows which
+opcode it lands on: `LOADNIL` 1080, `GETTABUP` 494, "not found" 326, `GETUPVAL`
+199, `MOVE` 52, … and `TFORCALL` **zero**. That branch (`change = reg >= a + 2`)
+cannot be observed from an error message: `TFORCALL` sits at the end of the loop
+body, the registers it touches are live locals inside the loop, and outside the
+loop they are always rewritten before anything reads them. It is transcribed
+verbatim from C; no case in 1032 lines reaches it, and that is a measured fact.
+
+### The file-method matrix: six methods × six modes × every argument kind
+
+`IoLib.LuaFile` was the least-covered class in the tree (0.758). The grid that
+targets it has nine blocks and prints **1262 lines**: the six file methods
+(`flush lines read seek setvbuf write`) over the six open modes over 21 argument
+values, then the same again on a *closed* handle; a **two-argument** `seek`
+matrix; write-then-seek; `read`'s multi-format forms and `lines` iteration;
+`write`'s multi-argument forms; a directory handle; a **second handle opened on
+the same path**; a failed `read("n")` followed by `read(1)`; and `popen`'s close.
+
+Two of those blocks exist because the obvious one is blind:
+
+* `f:seek(-1)` never reaches the offset code at all — argument 2 is the
+ *option*, so a number just means `invalid option`. Only `f:seek(w, off)`
+ exercises negative offsets, and it is worth **46 red lines**.
+* `seek` flushes the write buffer before reporting the position, so nothing
+ distinguishes a `flush()` that flushes from one that does not. Opening a
+ *second* handle and reading the file with it does: **4 red lines**.
+
+Two real divergences came out of it:
+
+* **`f:read(-1)` raised `NegativeArraySizeException`.** C writes
+ `size_t l = (size_t)luaL_checkinteger(L, n)` — a negative count becomes an
+ astronomical unsigned value, `luaL_prepbuffsize` fails to allocate, and
+ `resizebox` raises the ordinary Lua error `"not enough memory"`
+ (`false not enough memory` under `pcall`). `new byte[(int) n]` is not the
+ translation of that. `read_chars` now reads in chunks until the stream is dry
+ or the count is met — which is closer to `fread` anyway, since `fread` keeps
+ going on a short read — and only refuses when the size is one no system could
+ allocate. The exact refusal boundary is platform-dependent (the reference
+ binary cuts off around 2^46 on macOS, elsewhere with overcommit), so it is
+ deliberately **not** in the differential table; the deterministic input is the
+ negative one.
+* **A successful `fseek` discards the `ungetc` pushback.** `f:read("n")`
+ failing on `"three"` pushes `'t'` back; the following `f:seek("set", 0)` then
+ `f:read(3)` gives `"1 2"` in the reference and `"t1 "` in ours until `seek`
+ cleared it.
+
+Measured sensitivity: `seek`'s negative-position `EINVAL` 46, `read`'s directory
+check *crashes the whole grid*, append not returning to EOF 5, `flush` not
+flushing the write buffer 4, `seek` not clearing the pushback 3, `seek`'s
+directory base 2, `write`'s zero-byte special case 1.
+
+And one honest zero: **`tell`'s `+ wlen` is 0** — `f:seek` calls `flushW` before
+`tell`, so the buffer is always empty there. Coverage went up; the branch is
+still unobserved. Blocks 8 and 9 are likewise coverage-only: removing the
+`done >= len` short-circuit, or not closing `popen`'s `in`, leaves the grid
+green. They exist so JaCoCo has less to lie about, not because they pin a
+behaviour — most of what is left uncovered in `LuaFile` is `IOException` catch
+blocks, which need fault injection, not more scripts.
+
+### The `os.time` / `os.date` grid: the two functions the argument grid had to skip
+
+`os.time` and `os.date` were **blacklisted** from the library-argument grid because
+with no arguments they read the clock. With explicit arguments they are perfectly
+deterministic, so an entire area went unwatched. This grid is **1152 lines**:
+seven `os.time` fields × 23 values, the missing-field defaults (`hour` defaults to
+12, `year`/`month`/`day` have **no** default), `setallfields` writing the
+normalised fields back into the caller's table, 60 `os.date` formats × 15
+timestamps, `os.difftime`, `os.getenv`, `os.setlocale`.
+
+It found one real divergence: **`os.time{year=2147483647}` returned garbage.**
+The reference gives `67767976201968000`; we gave `-6019000092870206`. The cause
+is that `GregorianCalendar` holds **milliseconds**, so at that magnitude
+`getTimeInMillis()` overflows and wraps to a negative number. It now goes through
+`OsLib.civilToEpoch` (proleptic-Gregorian arithmetic in plain `long`), and the two
+agree — and `67767976201968000` is exactly `86400 * days_from_civil` minus the
+zone offset, i.e. **pure arithmetic**, so both libcs produce it.
+
+Two things are measured as out of scope rather than guessed at:
+
+* **Anything before 1900.** Apple libc's `mktime` refuses it (measured: `year =
+ 1900` works, `1899` does not); glibc does not. Neither implementation is "right",
+ so the probe prints `SKIP` for that band.
+* **`os.time(nil)` / `os.time()`** — `lua_isnoneornil` means the clock.
+
+Measured sensitivity: the conversion-specifier error text 54, `civilToEpoch`'s
+month base (`tmMon` is 0-based) 29, `isdst` always 0 / `isdst = 1` not shifting
+an hour 24, `setAllFields`'s field names 22, falling back to `Calendar` (overflow)
+3. And one honest zero: **`os.date`'s `MAX_SEC` guard is 0** — `2^60` falls outside
+it either way, so this grid cannot see that line.
+
+---
+
+## Migration from the old fakescript language
+
+This is a **breaking rewrite**. The old fakescript syntax is gone entirely:
+`package`, `include`, `struct`, `const`, `->`-style `for`, `array()`, `map()`,
+`fake` coroutines, `:=`, the Java binding annotations, and the profiler.
+
+Existing fakescript scripts must be rewritten as Lua — which is the point: after
+this change, what runs is Lua 5.4, and scripts are portable to and from any other
+Lua 5.4 implementation. Users who still need the old language should pin to the
+last commit before the rewrite.
+
+---
+
+## Build
+
+```bash
+./mvnw clean test # requires JDK 21+
+```
+
+With no `lua5.4` binary on the machine, the differential tests skip themselves
+(a single `assumeTrue` guard in `Diff`) and the coverage gate is skipped with
+them — the differential suites are most of the line coverage, so a gate that
+runs while they are all skipped would only ever be red for the wrong reason.
+Set `LUA54_BIN`, or build the reference into `target/lua54/src/lua`, to get the
+real gate:
+
+```bash
+LUA54_BIN=/path/to/lua5.4 ./mvnw clean test
+```
+
+CI builds the reference from source and exports `LUA54_BIN` and `FKDUMP_BIN`, so
+this only affects local builds without a reference binary.
+
+---
+
+## License
+
+MIT
diff --git a/README_CN.md b/README_CN.md
new file mode 100644
index 0000000..6581922
--- /dev/null
+++ b/README_CN.md
@@ -0,0 +1,2690 @@
+# fakescript-java
+
+[](https://github.com/esrrhs/fakescript_java)
+[](https://github.com/esrrhs/fakescript_java)
+[](https://central.sonatype.com/artifact/com.github.esrrhs/fakescript-java)
+[](https://github.com/esrrhs/fakescript_java/actions)
+
+**纯 Java 实现的 Lua 5.4 —— 不自己实现 GC,内存回收全部交给 JVM。**
+
+[English](./README.md)
+
+---
+
+## 设计目标
+
+> 让现有的 Lua 项目**一行代码都不用改**就能跑在 JVM 上。
+
+本项目是 **Lua 5.4 参考实现(`lua-5.4.x` C 源码)到 Java 的逐模块翻译**。
+它不是一门"借鉴了 Lua 的"语言——它就是 Lua 5.4,只是把内存管理那一层摘掉、
+交给了宿主运行时。
+
+| | |
+|---|---|
+| **目标** | 完整的 Lua 5.4 语言语义 + 完整的标准库 |
+| **方法** | 逐模块直译 C 参考实现 |
+| **GC** | **不实现**。对象生命周期完全交给 JVM(G1/ZGC 本身就是并发收集器) |
+| **运行环境** | Java 17+(协程是独立的栈,不用线程,见下) |
+| **兼容范围** | 纯 Lua 项目;C 扩展模块(`.so` / `luaopen_*`)本阶段不做 |
+
+### 为什么不写 GC
+
+C 实现的 `lgc.c` 是最复杂的部分之一:三色增量标记、分代模式、写屏障、终结器、弱表。
+它做的事,JVM 已经做得更好——并发、且调优过。所以这个文件整体删除,
+**只有那些"依赖回收动作"的语义**用 JVM 原语重建:
+
+| Lua 概念 | C 实现机制 | 本实现 |
+|---|---|---|
+| 对象生命周期 | `lgc.c` 三色标记 | **JVM GC**(G1 / ZGC) |
+| 弱表(`__mode = "k"/"v"/"kv"`) | `lgc.c` 里清扫弱链 | `WeakReference` + `ReferenceQueue` |
+| `__gc` 终结器 | `luaC_checkfinalizer` / `GCTM` | `java.lang.ref.Cleaner` |
+| 短字符串驻留 | `lstring.c` 的驻留表需抵抗回收 | 弱引用驻留池,无人引用即释放 |
+
+其余部分——词法、语法、代码生成、虚拟机、元方法、协程、标准库——都是直译。
+
+---
+
+## 架构
+
+包结构一比一对应 C 源码,拿着 `lua-5.4.x` 对照即可定位:
+
+| Lua 5.4 C 源码 | Java |
+|---|---|
+| `lobject.h` / `lobject.c` | `LuaValue`、`LuaString`、`LuaTable`、`Proto`、`Closure`、`Upvalue` |
+| `lstring.c` | `StringPool` |
+| `ltable.c` | `LuaTable` |
+| `lfunc.c` | `Proto`、`Closure`、`Upvalue` |
+| `lstate.c` | `LuaState`、`CallInfo`、`GlobalState` |
+| `lzio.c` | `Zio` |
+| `llex.c` | `Lexer` |
+| `lparser.c` | `Parser`(与上游一致的手写递归下降) |
+| `lcode.c` / `lopcodes.h` | `FuncState`、`Codegen`、`OpCode` |
+| `lvm.c` | `Interpreter` |
+| `ldo.c` | 调用帧、保护调用、错误传播 |
+| `ltm.c` | `Meta`(元方法派发) |
+| `ldebug.c` | `Debug`、错误信息、栈回溯 |
+| `lapi.c` / `lauxlib.c` | `LuaState` 上的操作、`AuxLib` |
+| `lundump.c` / `ldump.c` | 字节码载入 / 转储 |
+| `lbaselib.c`、`lcorolib.c`、`loadlib.c`、`lstrlib.c`、`ltablib.c`、`lmathlib.c`、`liolib.c`、`loslib.c`、`lutf8lib.c`、`ldblib.c` | 对应的 `lib.*` 类 |
+| `lgc.c` | **不存在** —— 见上 |
+
+`lauxlib.c` 等辅助层一并搬过来,因为标准库是照着它们写的。
+
+---
+
+## 关键设计决策
+
+以下几处是"把 C 翻译成 Java"不能机械照搬的地方,选错了后面要花很大代价才补得回来。
+
+### 1. Lua 字符串是字节串,所以不能用 Java `String`
+
+Lua 字符串可以包含 `\0`;`#s` 数的是**字节**;`s:byte(i)` 返回一个字节。
+Java 的 `String` 是 UTF-16,且不接受孤立代理项。任何处理二进制数据、UTF-8 文本
+或含 `\0` 的真实 Lua 项目,只要字符串底层用 `String` 就会出错。
+因此 Lua 字符串底层用 `byte[]`,所有字符串库函数按字节实现。
+
+### 2. 值保留整数/浮点子类型区分
+
+Lua 5.4 区分 `1`(integer)与 `1.0`(float):二者比较相等,但 `math.type` 不同,
+`//` 与 `/` 不同,`1/2` 是 `0.5` 而 `1//2` 是 `0`,转字符串分别是 `1` 与 `1.0`。
+值采用**可变槽**结构(`type` 标签 + `long` + `double` + `Object` 载荷),
+对应 C 的 `TValue`:既避免装箱,也让 VM 能原地写栈槽。
+
+### 3. 表保留数组部分/哈希部分的分离
+
+照搬 `ltable.c`,包括"这个整数键是否足够密集、能否放进数组部分"的迁移逻辑。
+整数键与字符串键是不同的项:`t[1]` 与 `t["1"]` 是两个不同的条目,与 Lua 一致。
+
+### 4. 协程是独立的栈,不是线程
+
+Lua 协程可以在任意深度的栈上 yield——`pcall` 内部、元方法内部、`for` 迭代器内部。
+原计划是每个协程给一条 Java 虚拟线程。阶段 5 实测下来这是个错的选择,已经改掉。
+
+Lua 协程是**协作式**的:同一时刻只有一个在跑,挂起点由 `coroutine.yield` 显式
+指出。C 版的实现就是「每个 `lua_State` 一条独立栈 + `longjmp` 跳回
+`lua_resume`」。Java 有同样的两样东西——每个 `LuaState` 一条独立栈,加上一个
+把 Java 调用栈弹掉的异常(`LuaYield`)。**根本不需要线程**:Lua 那边的状态全在
+堆上,弹掉 Java 帧之后,栈、`CallInfo` 链、开放 upvalue 都原样留着,`resume`
+时接着跑就行。
+
+虚拟线程还有个问题是**过于宽松**:C 函数回调进 Lua 的地方(`table.sort` 的比较
+函数、`string.gsub` 的替换函数、被 `print` 调用的 `__tostring`)在真实 Lua 里
+不能 yield,会报 `attempt to yield across a C-call boundary`;换成虚拟线程它就
+「能跑」了。对一个目标是「一行不改跑现有 Lua 代码」的项目来说,这个差异是 bug
+不是特性。
+
+顺带的好处是 Java 下限从 21 降回 17——本机现在的构建就是 17。
+
+取代线程的是 `ldo.c` 的忠实移植:`lua_resume` / `unroll` / `finishCcall` /
+`finishpcallk` / `precover`,外加 `luaV_finishOp`(把被 yield 打断的那半条指令
+补完)。会被打断的 C 函数(`pcall`、`xpcall`)注册一个续体,也就是 Java 版的
+`lua_KFunction`。
+
+### 5. 错误用 Java 异常,不用 `setjmp` / `longjmp`
+
+`lua_error` 变成抛出携带 Lua 错误值的异常。`pcall` / `xpcall` 捕获它,协程边界
+同样处理。需要小心处理:非字符串的错误值、带 `__tostring` 的错误对象、
+以及跨越协程边界传播的错误——都要与上游行为一致。
+
+### 6. 不再用 JFlex / Bison
+
+Lua 参考实现的解析器是手写递归下降。直接搬过来,就去掉了构建期代码生成这一步,
+以及旧架构里"生成物漂移"的问题。
+
+### 7. 元方法:全部实现,包括那些别扭的规则
+
+`__index`、`__newindex`、`__call`、`__concat`、`__len`、`__eq`、`__lt`、`__le`、
+`__unm`、`__add` …… `__idiv`、`__close`、`__gc`、`__mode`、`__name`、`__metatable`、
+`__pairs`、`__ipairs`。包括容易做错的部分:只有 `__lt` 时 `__le` 退化为
+`not (b < a)`;`__eq` 只在类型可比时触发;表上的 `__len` 忽略非整数结果;
+`__index` 的链式查找。
+
+---
+
+## 实施路线
+
+每一阶段结束都必须测试全绿;红着不许往下走。
+
+| 阶段 | 内容 | 上游对应 |
+|---|---|---|
+| **0** | 地基:`LuaValue`、`LuaString` 与驻留、`LuaTable`、`LuaState`、错误类型、`Zio` | `lobject`、`lstring`、`ltable`、`lstate`、`lzio` |
+| **1** | 词法 + 递归下降语法分析,按上游方式直接产出而不单独建树 | `llex`、`lparser` |
+| **2** | 字节码:完整 Lua 5.4 指令集、代码生成、常量折叠、upvalue 解析 | `lopcodes`、`lcode`、`lfunc` |
+| **3** | 解释器主循环:调用/返回、带类型转换的算术与比较、元方法派发 | `lvm`、`ldo`、`ltm` |
+| **4** | 闭包与 open/closed upvalue、`goto`/label、变长参数、多重赋值与多返回值 | `lparser`、`lvm` |
+| **5** | 协程:`coroutine.*`、yield/resume/unroll、yield 与错误跨边界 | `lcorolib`、`ldo`、`lvm` |
+| **6** | 依赖回收的语义:弱表、`__gc`、弱字符串驻留池 | *(取代 `lgc.c`)* |
+| **7** | 标准库:`base`、`coroutine`、`package`、`string`、`table`、`math`、`io`、`os`、`utf8`、`debug` | `l*lib.c`、`loadlib.c` |
+| **8** | `load` / `loadstring` / `dump`、`require` 搜索器、`package.path` / `cpath` | `lundump`、`ldump`、`loadlib` |
+| **9** | 一致性验证:上游测试套件 + 与真实 `lua5.4` 二进制的差分测试 | — |
+
+---
+
+## 怎么证明"它真的是 Lua"
+
+一个只是**看起来像** Lua 的重写是没有价值的。四道闸门,强度递增:
+
+1. **上游测试套件**。直接跑 `lua-5.4.x/tests/*.lua`(`alls.lua`、`big.lua`、
+ `strings.lua`、`coroutine.lua`、`pm.lua` 等),不做任何修改。这是主闸门——
+ 上游自己的测试集比这里手写的任何用例都刁钻。
+2. **差分测试**。同一份脚本分别在本实现与真实的 `lua5.4` 二进制下运行,
+ stdout 必须逐字节相同。CI 里装 `lua5.4` 后自动跑。
+3. **差分模糊测试**。生成程序与参考二进制对比——这一条专门抓"不崩但算错"那类缺陷。
+4. **覆盖率门禁**(JaCoCo 行覆盖率)。
+
+一个阶段只有在适用于它的闸门全绿之后才算完成。
+
+---
+
+## 现状
+
+**阶段 10(上游 `lua-5.4.x/tests` 端到端跑绿)已完成。** 19 个「独立可跑且输出
+确定」的用例文件(`api`、`bitwise`、`calls`、`closure`、`code`、`coroutine`、
+`db`、`errors`、`events`、`gengc`、`goto`、`nextvar`、`pm`、`strings`、`tpack`、
+`utf8`、`vararg`、`verybig` 等)与真实 `lua5.4` 的输出**逐字节一致**;
+`cstack` / `gc` / `literals` / `locals` / `constructs` 五个因为打印的是随实现或
+随随机数而变的东西,只断言「跑完并打印 `OK`」——理由逐个列在 *阶段 10* 一节里。
+全量 874 个用例绿,JaCoCo 行覆盖 0.96(门禁 0.95)。(上游套件这道闸门要
+`LUA54_TESTS` 或 `target/lua54-tests`;没有用例目录时整类自动跳过,
+所以那种机器上「绿」其实比看起来少 25 个用例。)
+
+**阶段 9(`debug` 库,含 `sethook`)已完成。** 旧的 fakescript 语言实现已从
+`master` 移除,见下文 *迁移说明*。阶段 0~9 已落地:编译器(词法 → 语法 → 代码生成)
+产出的字节码与真实 `lua5.4` **逐字段一致**,VM 已经能把它跑起来——158 + 121 + 60
++ 32 + 25 + 16 + 21 + 36 段差分脚本与 2100 个生成程序的 stdout 与真实 `lua5.4`
+**逐字节一致**,错误信息逐行一致。`string.dump` 的产物另外按原始字节比过,
+不只是比行为。十个标准库(base、coroutine、package、string、utf8、table、math、
+os、io、debug)全部落地,`debug` 是完整的——钩子也在内。
+
+阶段 6 是「不自己实现 GC」这个决定真正付出代价的一处。弱表、`__gc`、字符串池
+全部重建在 JVM 的可达性之上,而那与 Lua 的标记-清除**不是一回事**;有三处
+怎么也对不齐,列在下面的 *已知的不一致* 里。
+
+已落地:
+
+| 部件 | 上游对应 | 说明 |
+|---|---|---|
+| `Lua` 类型标签 | `lua.h`、`lobject.h` | 保留整数/浮点两个变体子类型 |
+| `LuaValue` | `TValue` | 可变槽,让 VM 能原地写栈槽 |
+| `LuaString` | `TString` | 底层 `byte[]`;`\0`、任意字节与非法 UTF-8 都原样保存 |
+| `StringPool` | `lstring.c` 的 `strt` | 弱引用驻留,不再引用的字符串不会钉住堆 |
+| `LuaTable` | `ltable.c` | 数组部分 + 哈希部分、`rehash`、`#t` 边界、整值浮点键规范化 |
+| `LuaError` | `lua_error` | 携带 Lua 值;只有这个类型会被 `pcall` 捕获 |
+| `Zio` | `lzio.c` | 字节输入流;`EOZ` 是 -1,字节按无符号取 |
+| `LCType` | `lctype.c` | 逐字节照搬 `luai_ctype_` 表,因此 `lislalpha` 与上游一样把 `_` 当字母 |
+| `Numeral` | `lobject.c` 的 `luaO_str2num` | 先整数后浮点;十进制用严格正则,把 `1d`/`inf`/`nan` 按 C `strtod` 的方式拒掉 |
+| `Lexer` | `llex.c` | 完整 token 集、`\z`/`\xXX`/`\u{XXX}`/`\ddd` 全部转义、长字符串与长注释、逐字节一致的错误信息 |
+| `OpCode` | `lopcodes.h/.c` | 83 条指令;顺序、名字与 `luaP_opmodes` 对着上游头文件机械校验过 |
+| `Proto` | `lobject.h`、`lfunc.c` | 指令、常量、嵌套原型、upvalue、局部变量、行号信息 |
+| `Arith` | `lobject.c`、`lvm.c` | 常量折叠用的算术——与将来 VM 用的是同一套 |
+| `ExpDesc`、`FuncState`、`DynData` | `lparser.h` | `expdesc` 的 union 摊平;块/goto/变量的簿记 |
+| `Codegen` | `lcode.c` | 发指令、分配寄存器、跳转与回填、常量折叠、`NEWTABLE`/`SETLIST` |
+| `Parser` | `lparser.c` | 手写递归下降,边解析边发指令 |
+| `UpVal` | `UpVal` | 两态:开放时持有栈槽引用,关闭时持有私有副本 |
+| `LClosure` | `LClosure` | 原型 + upvalue 数组 |
+| `CallInfo` | `CallInfo` | 帧记录;`CIST_FRESH` 决定 Lua→Lua 返回在哪一层停 |
+| `LuaState` | `ldo.c`、`lstate.c` | 栈、调用/返回、`pcall`、upvalue 开关闭、全部 `luaG_*` 错误 |
+| `LuaTm` | `ltm.c` | 元方法查找与派发、`__le` 退化、to-be-closed |
+| `Vm` | `lvm.c` | 解释器主循环,83 条指令全覆盖 |
+| `BaseLib`、`MathLib` | `lbaselib.c`、`lmathlib.c` 与部分 `lstrlib.c` | 最小集合,但每条都对过差分 |
+| `Diff`(测试) | — | 差分驱动的公共部分:一段脚本两边各跑一遍 |
+| `ConformanceTest`、`CodegenTest`、`ParserTest`、`RuntimeTest` | — | 以真实的 `lua5.4` 二进制作为裁判 |
+| `ClosureTest`(测试) | — | 阶段 4 闸门:闭包、goto、变长参数、多重赋值 |
+| `CClosure` | `CClosure` | 绑定了值的 C 函数,`coroutine.wrap` 需要它 |
+| 线程值(`Lua.VTHREAD`) | `LUA_TTHREAD` | 一个能被当作 Lua 值使用的 `LuaState` |
+| `CoroutineLib` | `lcorolib.c` | `create` / `resume` / `yield` / `status` / `wrap` / `isyieldable` / `running` / `close` |
+| `resume` / `unroll` / `finishCcall` / `finishpcallk` / `precover` | `ldo.c` | yield/resume 状态机,不用线程 |
+| `LuaKFunction` | `lua_KFunction` | 被 yield 打断的 C 函数的续体 |
+| `LuaYield` | `luaD_throw(L, LUA_YIELD)` | 用于展开的异常;刻意**不**被 `pcall` 捕获 |
+| `Vm.finishOp` | `luaV_finishOp` | 把被 yield 打断的半条指令补完 |
+| `CoroutineTest`(测试) | — | 阶段 5 闸门:60 段差分脚本,含在每个元方法里 yield |
+| `FuzzTest`(测试) | — | 1500 个生成程序,另有 600 个跑在协程里并随机撒 yield |
+| `LuaTable.WeakRef` | `lgc.c` 的 `iscleared` | 弱引用**就是**存储形式;额外记下身份哈希,因为 `identityHashCode(null)` 恒为 0,会把所有失效键一起塌到 0 号桶 |
+| `LuaTable.purge` / `rewrap` | `cleartable` / `traverseweakvalue` | 失效条目在一轮收集结束时摘掉;`__mode` 变化时整体重新包装 |
+| `LuaState.clearDeadStackSlices` | `traversethread` 的 atomic 分支 | 把所有线程栈的 `[top, stack_last)` 置 nil,只在 `collectgarbage` 里做 |
+| `GcSentinel` | `lgc.c` 的 `GCTM` | 只被目标表引用的哨兵;它的 `finalize` 把表复活到待终结队列,由 **Lua 线程**排空 |
+| `LuaState.close` | `lua_close` → `callallpendingfinalizers` | 退出时把还有 `__gc` 的对象全跑一遍 |
+| `WeakTableTest`(测试) | — | 阶段 6 闸门:20 段差分脚本,拆成「依赖收集时机」与「恒成立」两半 |
+| `GcTest`(测试) | — | 阶段 6 闸门:12 段脚本——只跑一次、错误静默、后置 `__gc` 不生效 |
+| `LuaUserdata` | `Udata` | 载荷 + **每对象**元表;`luaL_testudata` 要的正是后者 |
+| `Undump` | `lundump.c` | 读预编译 chunk:头部校验、变长长度、`binary string` 源名规则 |
+| `Dump` | `ldump.c` | 写预编译 chunk:`string.dump` 与 `strip` |
+| `LoadTest`(测试) | — | 阶段 8 闸门:25 段脚本——模式、chunk 名、`_ENV`、读函数、`loadfile` / `dofile` |
+| `DumpTest`(测试) | — | 阶段 8 闸门:16——运行期 stdout 与 `string.dump` 产物都逐字节比 |
+| `StringPack` | `lstrlib.c` 的一部分 | `string.pack` / `unpack` / `packsize`;标准库里唯一平台相关的一角 |
+| `PackTest`(测试) | — | 21 段脚本——整数、浮点、字符串、对齐、宽整数、报错、键集合 |
+| `DebugLib` | `ldblib.c` + `ldebug.c` / `ldo.c` / `lauxlib.c` 的一部分 | `getinfo` / 局部变量 / upvalue / `traceback` / 注册表 / 元表 / 用户值 / `setcstacklimit` / `sethook` / `gethook` |
+| `LuaHook` | `lua.h` 的 `lua_Hook` | 钩子回调;`DebugLib` 的 `hookf` 就是 `sethook` 装的那个 |
+| `LuaLibs` | `linit.c` | `luaL_openlibs`;按 `loadedlibs` 的顺序开十个库 |
+| `DebugTest`(测试) | — | 阶段 9 闸门:36 段脚本——层号、选项、局部变量、upvalue、`traceback`、深栈,外加 14 段钩子用例 |
+| `LuaLibsTest`(测试) | — | `openAll` 真的开全了十个,而且库表就是元表指的那张 |
+
+### 字节码这道闸门
+
+编译器是**用差分比对验证的,不是靠自称的预期值**。`src/test/resources/fkdump.c`
+是挂在参考 `lua5.4` 源码上的一个小转储器:对每份脚本,它把每条指令的
+**全部字段**(`a`、`b`、`c`、`k`、`Bx`、`sBx`、`Ax`、`sB`、`sC`、`sJ`)、行号、
+常量表、upvalue 表递归地打出来。`CodegenTest` 拿它和 `ProtoDump` 渲染出的
+`Proto` 逐行比对,要求完全相同。
+
+语料有两份:约 50 个覆盖各种语法结构的小脚本,以及**上游 `lua-5.4.7` 测试套件**
+的 33 个文件(CI 里下载到 `target/lua54-tests`),只编译不运行。全部一致。
+
+错误信息同样走差分:`ParserTest` 把写坏的源码落盘交给真实 `lua5.4` 跑,
+再逐字节比对消息——`'end' expected (to close 'if' at line 1)`、
+`label 'a' already defined on line 1`、` at line 1 jumps into the scope of local 'x'`
+等等。
+
+词法层是**用差分比对验证的,不是靠自称的预期值**:一批故意写坏的源码先落盘
+交给真实 `lua5.4` 跑,再把同一份字节喂给 `Lexer`,逐字比对错误信息(含
+`near ''` 那一段——`` 与其他 token 的加引号规则并不一样)。
+另一批用例反向验证:参考实现能加载的源码,这里也不许报错。
+
+本阶段有两条语义是**跑参考实现实测**定下来的,而不是照手册推的:
+
+* 整值浮点作键时,写入会**规范化成整数键**
+ (`t[1.0]` 之后 `math.type(next(t))` 是 `"integer"`,`#t` 是 1);
+* 写 **NaN** 键报 `table index is NaN`,而**读** NaN 键静默返回 `nil`。
+
+两条都由 `ConformanceTest` 里跑真实 Lua 5.4 的用例锁住。
+
+### 本阶段由参考实现裁定的三件事
+
+每一条看起来都像 bug,其实不是;三条都有测试锁住。
+
+* **`>>` 是逻辑右移**。`intop(>>,x,-y)` 先把操作数转成 `lua_Unsigned`,
+ 所以 `-8 >> 2` 是 `4611686018427387902`,不是 `-2`。
+* **上游的常量表里确实有重复项**。`ls->h`(「常量 → 下标」缓存)由整份源码共享,
+ 而每个函数的常量各自从 0 开始编号。内层函数会把缓存里的下标改成自己的小下标;
+ 回到外层时那个下标已经指向别的常量,校验失败,于是再追加一份重复的。
+* **整值浮点要用扰动过的缓存键**。Lua 的表用 `==` 判键,`100000` 与 `100000.0`
+ 是同一个键;没有 `luaK_numberK` 里那个 `r + r*2^-52`,两者会互相把对方从缓存
+ 里挤掉,每用一次就新建一份常量。
+
+### 运行期这道闸门
+
+`RuntimeTest` 是阶段 3 的闸门。每条用例都是一段脚本**跑两遍**——本实现一遍、
+真实 `lua5.4` 一遍——两份 stdout 必须相等。出错的用例比对消息行,含
+`(local 'x')` / `(constant '10')` / `(method 'nope')` 这类后缀。
+
+栈靠一条不变量撑着,其它一切都以它为前提:
+
+> 栈槽 `LuaValue` **永不被替换**。`LuaState` 永远做 `stack[i].set(v)`,
+> 绝不做 `stack[i] = v`。
+
+只有守住这一条,「开放 upvalue 持有栈槽的引用」在 Java 里才等价于 C 里的
+「开放 upvalue 持有栈槽的指针」。破一次,闭包就会静默地看不到更新。
+
+现在就可以这样跑:
+
+```java
+LuaState L = new LuaState();
+BaseLib.open(L);
+MathLib.open(L);
+L.run("print('hello from Lua')");
+```
+
+### 本阶段由参考实现裁定的六件事
+
+每一条最初都凭手册或直觉写错了,六条全部由差分测试锁住。
+
+* **算术不做字符串转换,`"10" + 1 == 11` 也不来自 VM。** `lvm.c` 的算术宏用的是
+ `tonumberns` / `tointegerns`,两者都不认字符串。转换在 `lstrlib.c`:它给字符串
+ 元表挂上 `__add` 等元方法,先把两侧 `lua_stringtonumber`,再 `lua_arith`。
+ 少了这一组,就会得到一个看起来合理但与参考实现不同的
+ `attempt to perform arithmetic on a string value`。
+* **C 函数的 n 个返回值是「栈顶最后 n 个」。** `ipairsaux` 先压 `i`、再压 `t[i]`,
+ 当 `t[i]` 是 nil 时 `return 1`——泛型 for 拿到的正是最后那个 **nil**,不是 `i`。
+ 按直觉写成「返回 i」,`for _, v in ipairs(t)` 永远不会终止,因为循环只在
+ **第一个**返回值为 nil 时退出。
+* **每条算术指令后面必定跟一条 `OP_MMBIN*`。** 成功就 `pc++` 跳过它,
+ 失败就自然落下去派发元方法。漏一个 `pc++`,正确的表达式也会多跑一次元方法。
+* **`OP_NEWTABLE` 的 `EXTRAARG` 是无条件的**(因为 `k == 0` ⟺ `Ax == 0`),
+ 而 `OP_SETLIST` 的是有条件的。按条件读前者,每个表构造都会报
+ `malformed opcode EXTRAARG`。
+* **C 的 `%g` 会去掉末尾的零,Java 的不会。** 在手写 `formatG` 之前,
+ `1.5` 被打印成 `1.5000000000000`。
+* **`luaG_runerror` 与 `luaL_error` 取位置的方式不同。** 前者看**当前**帧的
+ `isLua(ci)`;后者是 `luaL_where(L, 1)`,会往回跳一层到调用者。库函数用错了
+ 前者,就完全没有 `src:line:` 前缀——因为它自己那帧是 C 帧。
+
+### 差分 fuzz 这道闸门
+
+`FuzzTest` 用一个小文法生成 2100 段程序,逐段与参考实现比对:1500 段常规程序,
+另有 600 段把主 chunk 塞进 `coroutine.create`、随机撒 `coroutine.yield`,再用一个
+有界的 `resume` 循环驱动。两条约束保证它
+不会变成一个麻烦制造机:
+
+* **必须终止。** 只生成有界的 `for` / `ipairs`,不生成 `while`、`repeat`、
+ `goto`;函数调用只允许指向**更早定义完**的函数,于是调用图是 DAG,
+ 递归与死循环都不可能。
+* **必须可复现。** 种子从 0 递增,不用 `Math.random`。失败时凭「第几个种子」
+ 就能精确复现。
+
+生成器不知道我们认为 Lua 该怎样——这正是它的价值:它是唯一能抓住
+「不崩、但答案错」的闸门。
+
+### 本阶段由参考实现裁定的四件事
+
+* **`luaF_newtbcupval` 在赋值当下就检查 `__close`,不是在作用域结束时。**
+ `OP_TBC` 紧跟在局部变量拿到值之后,所以 `local x = 5 print(x)`
+ 会在打印之前就抛 `variable 'x' got a non-closable value`。`nil` 与 `false`
+ 豁免(`l_isfalse`),所以 `local x = nil` 是合法的。
+* **展开过程中 `__close` 里的错误会顶掉原错误。** `error('orig')` 之后
+ `__close` 里再 `error('in close')`,`pcall` 拿到的是 `in close`。
+ 沿用原错误是错的。
+* **代码生成只折叠 `OPR_MINUS` 与 `OPR_BNOT`,从不折叠 `OPR_LEN`。**
+ 所以 `#7.5` 是**运行期**错误,不是加载期错误。折叠它会让一段上游能编译、
+ 只在运行时才失败的脚本变成编译不过。
+* **两条闸门自身的规则,都是踩过才知道。** 脚本出错时它已经打出的 stdout
+ **仍然要参与比对**(参考实现的 stdout 与 stderr 是一起捕获的,「先打十行
+ 再崩」的输出里两者都有);而 `lua.c` 加的程序名前缀要**全文抹掉**,
+ 不能只抹开头——一旦 stdout 在前面,它就出现在输出中间了。
+
+### 本阶段被参考实现裁定的五件事
+
+* **`pcall`、`xpcall`、元方法、`for` 迭代器都可以 yield;标准库的回调不可以。**
+ `table.sort` 的比较函数、`string.gsub` 的替换函数、被 `print` 调用的
+ `__tostring` 都会报 `attempt to yield across a C-call boundary`——因为
+ `ltablib.c` / `lstrlib.c` / `luaL_callmeta` 是经 `lua_call`
+ (`luaD_callnoyield`)回调进 Lua 的。这恰恰是虚拟线程会「修好」、
+ 而实际上是改错的行为。
+* **主线程永远不可 yield。** `lstate.c` 的 `lua_newstate` 无条件执行
+ `incnny(L)`,所以主协程上 `coroutine.isyieldable()` 是 `false`,哪怕一个 C
+ 调用都还没发生。协程起始为 0,每次 `resume` 也重置为 0(`lua_resume` 只从
+ resume 方拷 `nCcalls` 的低 16 位)。
+* **`resume` 的参数就是 `yield` 的返回值。** `lua_resume` 里那句
+ `poscall(ci, nargs)` 决定了 `resume(co, 99)` 之后 `coroutine.yield(1)` 看到的
+ 是 `99`——搬回去的值的个数是传进来的个数,不是 yield 出去的个数。
+* **`coroutine.wrap` 报错用的是自己的名字,不是 `create` 的。** `luaB_cowrap`
+ 直接调 `luaB_cocreate`,而 `luaL_argerror` 的函数名是从「当前帧是被谁调用的」
+ 倒推出来的,所以 `coroutine.wrap(nil)` 报的是
+ `bad argument #1 to 'coroutine.wrap'`。
+* **`luaV_finishOp` 每个可中断操作码都有一个分支,`OP_CONCAT` 那个不能省。**
+ 拼接是一批一批做的,`__concat` 里 yield 之后要把元方法结果往回挪一格、把
+ `top` 收一格,再把剩下的元素拼完——可能又 yield 一次。省掉它的表现是
+ 拿到**操作数那张表**而不是拼接结果。
+
+### 阶段 6:参考实现裁定的五件事
+
+* **GC 可见的栈到 `L->top` 为止,这一点是承重的。** `lgc.c: traversethread`
+ 只 mark `[stack, top)`,然后在 atomic 阶段把 `[top, stack_last)` 整段置 nil
+ ——源码注释就叫 “clear dead stack slice”。所以**落在 `top` 之上的寄存器里的
+ 值对收集器不可见**,会被收掉。两种形态依赖这一点:C 函数压出来的值(比如
+ `next` 取回的键,调用结束 `top` 回落,槽还攥着它),以及循环最后一次迭代
+ 建的表(`for i = 1, 5 do t[i] = {i} end` 之后 `{5}` 还留在寄存器里,而
+ `L->top` 只到调用参数处)。我们这边 `stack` 是条 Java 数组,**每个槽都是活
+ 引用**,所以 `LuaState.clearDeadStackSlices` 必须把这一步补回来——而且得在
+ `collectgarbage` 里做,不能在每次改 `top` 时做(见下一条)。
+* **……但在每次改 `top` 时急切地清,会撞坏两处。** C 只在 atomic 阶段清。
+ 照抄到内联位置,会静默毁掉 `luaT_callTMres` 的结果(目标寄存器**就是**弹出
+ 的那一格),以及 `pcall` 的错误对象(`LuaError.value` 直接别名了活栈槽——
+ `error('boom', 0)` 抛的就是 `L.arg(0)` 本身——于是 `pcall` 变成返回
+ `false nil`)。等到 `collectgarbage` 再清既忠实,也同样精确。
+* **弱表的存储必须「本身是弱的」,不是在旁边挂一个 `WeakReference`。** 槽里的
+ 载荷就是那个 `WeakReference`;载荷若是强的,就什么也释放不掉。这带来一个
+ 后果:弱表上 `LuaTable.get` 只能交出**副本**,于是写路径那条快速分支
+ (`OP_SETTABLE` 的「槽已存在,直接写进去」)不能用在弱表上——往副本里写等于
+ 没写,症状是 `t.f = g` 之后 `t.f` 还是 `f`。
+* **`__mode` 是动态读的,而且要把已在表里的条目重新包装一遍。** 先建表、塞好
+ 条目、再 `setmetatable(t, {__mode = 'k'})`,那些条目照样会被回收——实测过。
+ 既然弱是本实现的存储形式,模式一变就得遍历全表重新包装;而且弱表只能把所有
+ 东西放在哈希部分(数组槽是普通 `LuaValue`,放不了 `WeakRef`)。
+* **`__gc` 要求对象还「活着」,这是 JVM 所有引用类型都给不了的。** 每一种
+ 「它死了」的通知(`WeakReference` 被清、`Cleaner`、`PhantomReference`)都发生
+ 在对象已经不可达之后,引用再也取不回来。只有 `finalize()` 是 `this` 还能用
+ 的时候被调到,而且**允许复活**。于是挂了 `__gc` 的表会得到一个只有它引用的
+ 哨兵:两者一起不可达时,哨兵的 `finalize` 把表塞进待终结队列,下一次
+ `collectgarbage` 在 **Lua 线程**上排空——绝不能在 JVM 终结线程上调 Lua 函数,
+ 那里既会和 `LuaState` 抢数据,抛出的异常也会被 JVM 连日志都不留地吞掉。
+
+### 已知的三处与参考实现不一致
+
+这些不是疏漏,每一处都是 JVM 没有对应原语的地方,如实记着而不是糊过去。
+
+* **ephemeron 只做对了「值就是键」这一种。** `__mode='k'` 下 `t[k] = k` 会摘掉
+ 条目(与参考实现一致),常见的缓存形态 `cache[obj] = {新数据}` 会保住值(也
+ 一致)。但 `t[k] = {k}`——值**包含**键、键又没有别的引用——本实现会留着,
+ 而 Lua 会摘掉。两半在 Java 里互斥:值存强的会把键养活,值存弱的又会在「键
+ 活着但值没有别的引用」时把值弄丢,而那正是最常见的缓存用法。丢数据比漏摘
+ 条目严重,所以选了漏摘。
+* **键挂了 `__gc` 的弱条目会早一轮被摘掉。** Lua 会 mark 带终结器的对象,于是
+ 那一轮条目还在;本实现靠 `WeakReference` 被清来判死活,同一轮就摘了。
+* **挂了 `__gc` 的对象只在 `collectgarbage` 时才被回收。** JVM 除了真收掉它,
+ 没有别的办法告诉我们「它不可达了」,所以没有显式收集周期就排不上终结器。
+
+### 阶段 7 到目前为止
+
+`string`(`StringLib`)、`table`(`TableLib`)、`math`(`MathLib`)、
+`utf8`(`Utf8Lib`)、`os`(`OsLib`)、`io`(`IoLib`)、`package`
+(`PackageLib`)已经搬完,各带一道差分闸门:`StringLibTest`(29)、
+`MathLibTest`(26)、`TableLibTest`(18)、`Utf8LibTest`(13)、
+`OsLibTest`(24)、`IoLibTest`(35)、`PackageLibTest`(22)。
+
+### 阶段 7 里由参考实现裁定的十件事
+
+* **共享的 `lauxlib.c` 那一层必须**先于**任何库落地。** 参数检查
+ (`checkString` / `checkInteger` / `argError` / …)不是脚手架:错误文本本身
+ 就是可观测契约的一部分,六个库各抄一遍,一周内就会彼此走样。其中一处只有
+ 在「参数从 1 起编号」时才对:`s:rep(nil)` 报的是 `bad argument #1`,因为
+ `luaL_argerror` 对方法调用会减一。
+* **`math.max` / `math.min` 用的是 `lua_compare`,不是数字比较。** 所以
+ `math.max("a","b")` 是 `"b"`,`math.max(1,"b")` 报的是
+ `attempt to compare number with string`(走 `__lt` 失败那条路)。它们还保留
+ 操作数的子类型:`math.max(1, 2.0)` 是 `2.0`(float)。
+* **`math.fmod` 的浮点路径是 C 的 `fmod`,不是 Lua 的 `%`。** 两者结果符号
+ 不同:`math.fmod(-7.5, 2)` 是 `-1.5`(取被除数符号),而 `-7.5 % 2` 是
+ `0.5`(取除数符号)。顺手复用 VM 的 `Arith.modf` 是最容易犯的错,且正好
+ 差一个符号。
+* **`math.log` 的 base 2 / base 10 两条分支确实是不同的计算。** C 调的是
+ `log2` / `log10`:`log(x)/log(2)` 与 `log2(x)` 在 43 个样本里有 9 个不同,
+ `log(x)/log(10)` 与 `log10(x)` 更是差 25 个。Java 没有 `log2`,这里用尾数
+ 分解 `x = m * 2^e` 还原成 `e + log(m)/ln2`,192 个样本里 190 个落在 1 ulp 内。
+* **`table.insert` 落在带 `__newindex` 的对象上必须真的调它。** `ltablib.c`
+ 用的不是 `luaH_setint` 而是 `lua_seti`,后者走 `luaV_finishset`。写成裸写
+ 会让 `table.insert(ud, v)` 静默地什么都不做——不报错,也不写入。
+* **`os.execute` 起的进程名必须是 `sh`,不能是 `/bin/sh`。** C 的 `system()`
+ 是 `execl("/bin/sh", "sh", "-c", cmd)`——**argv[0] 是 `sh`**——而 shell 的
+ 诊断信息是用 argv[0] 打头的。写成全路径会把 `sh: 42: command not found`
+ 变成 `/bin/sh: 42: command not found`,差分闸门立刻就能看到。
+* **userdata 的元表在对象上,不在类型上。** `luaL_testudata` 问的是「这个
+ userdata 的元表是不是注册在 `FILE*` 名下那一张」,所以文件句柄不能跟内部
+ userdata(随机数状态、`gmatch` 的状态)共用一张按类型分的元表,否则后者
+ 会被当成文件。为此补了 `LuaUserdata`,顺带补了它的 `__gc`——不然脚本里忘了
+ `f:close()` 的文件永远不会关。
+* **真实文件上的 `io` 必须块缓冲,而 `io.write` 不能缓冲。** C 的 stdio 对
+ 文件是缓冲的:`io.output(p):write("x")` 之后把 `p` 读回来是**空的**,那几
+ 个字节还在 `FILE*` 里。标准流正好相反——它们是行/无缓冲的,而且 `io.write`
+ 的输出必须落到宿主装的那条流上。所以 `IoLib` 对 `RandomAccessFile` 的写做
+ 缓冲(`flush`、`seek`、任何读、以及关闭时落盘),对 `stdin` / `stdout` /
+ `stderr` 则直接写穿。
+* **`require` 判断搜索器结果用的是 `lua_isstring`,不是「是不是字符串」。**
+ 返回**数字** `42` 的搜索器,那个 `42` 会被拼进「找不到模块」的消息里;
+ 只有既非字符串又非数字的结果才被当成「这个搜索器没说什么」,并把刚加的
+ `\n\t` 前缀退掉。
+* **加载期错误的源名也要过 `luaO_chunkid`。** 模块文件是按 `"@" + 路径` 编
+ 译的,但 `luaG_addinfo` 打印前会把 `@` 剥掉,所以 require 一个语法错误的
+ 模块报的是 `/tmp/m.lua:1: syntax error near 'is'`,不是 `@/tmp/m.lua:1: ...`。
+* **每个库都要同时登记进 `_LOADED`,不能只进 `_G`。** 漏掉的是 `coroutine`:
+ 它当时只在全局表里,于是 `require("coroutine")` 报
+ `module 'coroutine' not found`。
+
+### 一处承认下来、不去修的偏离
+
+`sin` / `cos` / `tan` / `asin` / `acos` / `atan` / `exp` / `log` / `log10` /
+`sinh` / `cosh` / `tanh` / `pow` 与参考实现**不是逐位相同**。Java 的 `Math`
+是 fdlibm,参考二进制链的是平台 libm(本机是 macOS 的 `libsystem_m`)。两者
+对真值的误差都在 1 ulp 之内,但最后一位的舍入不同:43 个典型输入里 `sin`
+差 1 个、`tan` 差 12 个、`cosh` 差 6 个。要消掉就得自带一份 libm,那既超出
+目标,也不符合 Lua 自己给出的保证——Lua 的结果本来就随平台漂。所以差分用例
+对这几个函数只比到 14 位有效数字(1 ulp 约 2.2e-16 相对误差)。能对齐的地
+方都对齐了:`sqrt`、`abs`、`floor`、`ceil`、`frexp`、`ldexp` 以及上面那两条
+`log` 分支都是逐位一致的。
+
+### `io` 里四处承认下来、不去修的偏离
+
+* **`file (%p)`。** `f_tostring` 打的是指针,`print(io.stdout)` 无论如何也不
+ 可能逐字节相同。`IoLib` 给一个同形状的十六进制值,差分比对把 `0x…` 抹掉。
+* **`stdin` 的定位。** 参考实现在测试里的 stdin 被定向到 `/dev/null`,那是
+ **可定位**的字符设备(`lseek` 返回 0);本实现这边是字节数组,定位不了。
+ `io.stdout:seek()` 两边一致(管道上是 `nil, "Illegal seek", 29`);
+ `io.stdin:seek()` 不比对——差的是流的来源,不是 `io` 的行为。
+* **更新模式上「读完不定位直接写」。** 字节落到哪里由 C 的预读缓冲定,ISO C
+ 里是未定义行为。实测:12 字节的文件落到 13、6002 字节的落到 4097、
+ `read(5000)` 之后落到 5001。本实现写在逻辑位置上。良定义那条路(中间有
+ `seek`)两边一致。见下文 *`io.open` 的模式矩阵*。
+* **append 档写完之后的位置。** 数据在缓冲里时参考给 `base+n`,压掉
+ 缓冲后给 `EOF+n`(分界是 4096);本实现一律给 `EOF+n`(这一边符合 POSIX)。
+ **写进去的内容两边始终一致**。见同节。
+
+### `package` 里两处承认下来、不去修的偏离
+
+这里如实走 `loadlib.c` 的兜底分支:JVM 上没有 `dlopen`(真要接得走 JNI 或
+FFI,那是另一个工程),所以 C 搜索器的行为就是 C 版在「未启用动态库」的
+平台上的行为。
+
+* **`package.loadlib`** 返回 `nil, "dynamic libraries not enabled; check your
+ Lua installation", "absent"`。参考二进制是用 `dlopen` 编出来的,那边给的
+ 是 `dlerror()` 的文本和 `"open"`。
+* **`require` 一个真能找到 `.so` 的模块**会停在「动态库未启用」这一步,
+ 而不是真的加载它。只有 `.lua` 的模块与 `package.preload` 里的条目不受影响。
+
+### 阶段 8 到目前为止
+
+`load` / `loadfile` / `dofile`(`BaseLib`)与二进制 chunk 那对
+`lundump.c` / `ldump.c`(`Undump` / `Dump`)已经落地,各带一道差分闸门:
+`LoadTest`(25)与 `DumpTest`(16)。
+
+`DumpTest` 用**两种粒度**比,第二种才是这一阶段的硬闸门:
+
+1. **运行期**:脚本的 stdout 逐字节比,与其它闸门一致;
+2. **产物**:两边各自把 `string.dump(f)` 写成文件,再比这两个文件的字节。
+
+只比第一种的话,「编译产物略有偏差、但恰好能跑」是漏得过去的。比产物则把
+常量顺序、行号表、局部变量与 upvalue 的调试信息、`maxstacksize` 一起钉住了。
+目前比了八种形状:普通、内嵌函数、变长参数、带 upvalue、`goto`、循环、各类
+常量,以及一个超过 `LUAI_MAXSHORTLEN` 的常量字符串(dump 与 undump 两侧都走
+不驻留的长字符串那条路)。
+
+### 阶段 8 里由参考实现裁定的六件事
+
+* **`luaU_undump` 在报错之前会先改写源名。** 开头的 `@` 或 `=` 要去掉;如果
+ 源名以签名首字节开头(也就是源名本身就是那段二进制 chunk——
+ `load(s)` 不给第二个参数时正是这样),就整段换成字面量 `binary string`。
+ 于是 `load("\27Lua")` 报的是
+ `binary string: bad binary format (truncated chunk)`,不是一串 ESC 开头的乱码。
+* **二进制那条路 `f_parser` 吃掉首字节,文本那条路要还回去。**
+ `luaU_undump` 的注释写得直白:"skip 1st char (already read and checked)"。
+ 在 undump 之前回退,整个头部会错开一个字节,`truncated chunk` 就变成了
+ `not a binary chunk`。
+* **`luaO_chunkid` 有第三种形态,而 `load` 碰到的正是它。** 只有 `@x` 与
+ `=x` 是剥前缀,其余一律包成 `[string "x"]`。`load("x++")` 报的是
+ `[string "x++"]:1: syntax error near '+'`——只剥不包的实现会报 `x++:1:`,
+ 于是每一条加载期错误都是错的。
+* **源名是字节串,`luaO_chunkid` 按字节截。** 先解码成 Java `String`
+ 再截,遇到非 UTF-8 的名字长度就对不上。所以 `chunkId` 的核心写成 `byte[]`
+ 版,`String` 那个重载走它。
+* **没有源名的原型,位置整段都不给,连行号也不给。**
+ `luaG_addinfo` 是 `if (src) ... else 原样返回 msg`。这一点是可观测的:
+ `string.dump(f, true)` 会剥掉源名,于是
+ `pcall(load(string.dump(f, true)))` 报的是裸的 `boom`。退化到「用 chunk 名
+ 兜底」不但多一截前缀,还会把那段二进制 chunk 塞进消息里——它是非法 UTF-8,
+ 解码出来就是替换字符。
+* **`lundump` 里的 `default: lua_assert(0)` 是空操作。**
+ `lua_assert` 在默认构建下编成 `((void)0)`,所以常量类型字节写成 `99` 的
+ chunk 能正常加载,那个常量就停在 `nil` 上。在那儿报错是我们自己发明语义。
+ 这一条是量出来的(手工拼的 chunk 在参考实现上加载成一个 `function`),
+ 不是推出来的。
+
+dump 那一侧还有一条:`dumpFunction` 比源名用的是引用
+(`f->source == psource`)而不是内容。短字符串驻留之后两者等价,但长字符串
+不驻留,C 会为每个内嵌函数各写一遍源名;按内容比会写得比 C 少。
+
+### `string.pack` / `string.unpack` / `string.packsize`
+
+作为 `StringPack`(`lstrlib.c` 的 pack/unpack 那半段)落地,配 `PackTest`
+(21 段)。这是标准库里**唯一一处输出取决于宿主平台**的部分:`l` / `L` 是
+C 的 `long`,`T` 是 `size_t`,`!` 的默认最大对齐是 `LUAI_MAXALIGN` 的偏移,
+默认字节序取宿主的。这些按参考二进制所在的平台(LP64 + 小端)钉死——
+在 `long` 只有 4 字节的平台上,参考实现自己给出的字节也不一样。
+
+这里还有两处由参考实现裁定的:
+
+* **`X` 会吃掉它后面那一项。** `getdetails` 调 `getoption` 是为了拿到后一项的
+ 对齐要求,而那次调用同时把格式串往前推了一格。于是
+ `string.packsize("Xi4")` 是 `0`,`string.pack("Xi4", 7)` 得到空串。
+ 「先看一眼再决定」的实现会给出 `4`——看着更合理,但它是错的。
+* **`str_pack` 那个 `lua_pushnil` 标记是可观测的。** 它本来只是把参数与字符串
+ 缓冲隔开,但它站在 2 号位置,所以不传第二个参数时
+ `string.pack("c2")` 报的是 `string expected, got nil` 而不是
+ `got no value`。
+
+### 阶段 9 —— `debug` 库,钩子也在内
+
+`ldblib.c` 落地为 `DebugLib`,顺带把 `ldebug.c` 里只有内省接口才用到的那部分
+(`lua_getstack` / `lua_getinfo` / `luaG_findlocal` / `luaG_traceexec` /
+`luaG_tracecall`)与 `lauxlib.c` 的 `luaL_traceback` 一起搬进来。闸门是
+`DebugTest`(36 段脚本)。十六个函数全部落地。
+
+`LuaLibs.openAll`(`linit.c` 的 `luaL_openlibs`)也一并落地——之前「开标准库」
+是十次调用,只有测试驱动知道顺序。
+
+#### 钩子这套机制,以及它的调用点都在哪儿
+
+`sethook` 不是一个能单独搬的库函数:它只是一个分发器,真正的调用点散在
+解释器各处。搬它意味着除了 `DebugLib.dHook`(`ldo.c: luaD_hook`)之外还要补:
+
+| 调用点 | 上游 | 触发 |
+|---|---|---|
+| `LuaState.precallC` | `ldo.c` | C 函数的 `call`——在 C 函数本身之前 |
+| `DebugLib.dHookCall`,由 `Vm` 的 `startfunc` 与 `OP_VARARGPREP` 调 | `ldo.c: luaD_hookcall` | Lua 函数的 `call` / `tail call` |
+| `DebugLib.rethook`,由 `LuaState.poscall` 调 | `ldo.c: rethook` | 两类函数的 `return` |
+| `DebugLib.traceExec`,由 `Vm` 的 `vmfetch` 调 | `ldebug.c: luaG_traceexec` | `line` 与 `count` |
+
+外加 `CallInfo.trap` 与 `LuaState` 上的 `hook` / `hookmask` / `hookcount` /
+`allowhook` / `oldpc`。`trap` 的意义在于它是 `luaV_execute` 里的一个局部
+变量——不开钩子时主循环连分支都不走。
+
+#### 又四件由参考实现裁定的事
+
+* **`lua_sethook` 必须调 `settraps`。** 主循环里的 `trap` 是局部变量,只在少数
+ 几个点(`updatetrap`)才从帧上重读。所以「执行到一半把钩子打开」在别的帧上一
+ 点用都没有,除非把每个活着的 Lua 帧的 `trap` 都置成 1——这正是 `settraps` 做的。
+ 少了它,`debug.sethook(f, "l")` 一个行事件都不会来。
+* **钩子里的错误,不会被它所报告的那个 `pcall` 抓住。** `pcall` 的 `call`
+ 钩子是在 `precallC` 里、在 `pcall` 建好恢复点之前报的,所以
+ `pcall(function() ... end)` 抓不到「钩子在 `pcall` 自己身上抛的错」。
+* **`funcnamefromcall` 里 `CIST_HOOKED` 要判在 `isLua` 之前。** 带这个标记
+ 的那一帧常常是 C 帧(`pcall` 的调用钩子就打在 `pcall` 自己的 C 帧上),
+ 先判 `isLua` 永远走不到这一支——回溯里那一行会从 `in hook '?'` 变成
+ `in function <文件:行>`。
+* **钩子永远不能 yield。** `hookf` 用 `lua_call` 调用户函数,而 `lua_call` 走
+ `luaD_callnoyield`,「不可 yield」的层数在钩子里非零。实测:钩子里
+ `coroutine.yield()` 在主线程上报 `attempt to yield from outside a coroutine`、
+ 在协程里报 `attempt to yield across a C-call boundary`,两种情况都让程序/协程
+ 死掉。这就是为什么本实现根本没搬 `CIST_HOOKYIELD`——从 Lua 里走不到
+ (上游那个标记是给「由 C API 直接装的 C 钩子」用的,那种钩子不走 `lua_call`)。
+
+#### 由钩子这道闸门抓出来的一个顺序 bug
+
+`allowhook` 必须在保护调用边界上恢复,而且是在消息处理函数跑完之后,
+不是在它之前。处理函数是在抛出点跑的,那时 `allowhook` 还是 0(正是这一点让回溯
+不会重新进钩子)。提前恢复成 1,回溯就会再触发一次钩子、再抛一次错,最后被
+`applyErrMsg` 兜成 `error in error handling`。症状是
+`debug.sethook(function() error("x") end, "c")` 之后那个未捕获错误打出来的是
+`error in error handling`,而不是真正的回溯。
+
+#### 阶段 9 里由参考实现裁定的五件事
+
+* **层号 0 是 `getinfo` 自己的 C 帧。** `lua_getstack(L, 0, &ar)` 会成功,
+ 给出 `what = "C", currentline = -1, name = "getinfo"`。名字来自
+ `funcnamefromcall` 读调用者那一帧,所以它回答的是「调用者当时在调谁」,
+ 不是「这一帧叫什么」。搞错一处,所有 `debug.getinfo(n)` 整体错一层。
+* **`'f'` 与 `'L'` 要 `lua_rotate`,不能弹。** 结果表是 `lua_getinfo` 之后
+ 才压的,所以 `lua_getinfo` 自己的产物在它底下。
+ `treatstackoption` 是 `lua_rotate(L, -2, 1)` 再 `lua_setfield(L, -2)`。
+ 写成 `L.top--` 弹掉的是结果表本人——症状是 `debug.getinfo(1)` 返回一个
+ 打印出来像函数的东西,而 `debug.getinfo(1).func` 里存着那张表。
+* **`pushfuncname` 先试全局名。** `error` 在 `getobjname` 眼里是
+ `global 'error'`(一条 `GETTABUP _ENV`),但回溯打的是 `function 'error'`——
+ `pushglobalfuncname` 在 `_LOADED._G` 里按值反查到了它,并且优先。
+ 只看 `ar.namewhat`,每个有全局名的 C 函数的修饰语都会错。
+* **消息处理函数在栈还没退的时候跑** —— `luaG_errormsg` 是在抛出点调它的,
+ 不是在 `luaD_pcall` 的恢复里。这就是 `xpcall(f, debug.traceback)` 能打出完整
+ 回溯、而不是只剩最外面两层的原因。Java 没有 `longjmp`,但 `catch` 到的那一刻
+ Lua 栈(`stack` / `top` / `ci`)还是完好的,所以把处理函数放在
+ `pcall` 的 `catch` 最前面跑是等价的。
+* **`LEVELS1` / `LEVELS2` 是 10 和 11**,不是整整齐齐的 10/10,头尾也不等长。
+ 否则「(skipping N levels)」那个数差 1——而且只在深到会触发它的栈上才暴露。
+
+#### 由 `debug` 这道闸门抓出来的两个 bug,都不在 `debug` 里
+
+* **`OP_TESTSET` 的判条件写反了,于是 `and` / `or` 是错的。** 上游判
+ `l_isfalse(rb) == k`,这里判成了 `toBoolean() == (k != 0)`——这两者在 `k`
+ 的两个取值上恰好相反。它静默:`TESTSET` 只在「结果寄存器 ≠ B」时才
+ 保留下来(`patchTestReg` 否则把它改写成 `OP_TEST`),于是
+ `if x then` 一直是对的,`x and y` 一直是错的。症状是
+ `print(t and "yes")` 打印出那张表;更糟的是 `cond and a or b`——Lua 里最常用的
+ 惯用法——整体取错分支。**字节码闸门看不见它**:两边的字节码逐字段相同。
+ `RuntimeTest.logicalOperatorsReturnValues` 现在钉住的是返回的哪个值,
+ 不只是真假。
+* **`xpcall` 的处理函数跑在调用链已经恢复之后。** 见上面第四条:
+ `xpcall(f, debug.traceback)` 的回溯只有两层,应该是七层。
+
+#### 一处由参考实现裁出来、但属于「驱动方式」而不属于引擎的差异
+
+`lua.c` 是在 `pmain`——一个 C 函数——里面跑脚本的,所以主 chunk 底下多一个
+C 帧。它表现为每次回溯末尾那行 `[C]: in ?`,以及
+`debug.getinfo(3).what == "C"`。宿主直接 `luaL_dostring` 时没有这一帧,两者都
+看不到。所以这是修在差分驱动上的(`Diff` 现在照 `lua.c` 搭:`pmain` + `docall`
++ `msghandler`),不是修在引擎上——为了让引擎凑上它,反而会让 `luaL_dostring`
+变成错的。
+
+### 阶段 10 —— 把 `lua-5.4.x/tests` 端到端跑绿
+
+第四道闸门里最重的一块:`UpstreamSuiteTest` 原样跑上游那套 `.lua`,输出与参考
+二进制逐字节比;`UpstreamSmokeTest` 收那些「不该逐字节比、但必须跑完」的。
+
+分到后者的四个文件,理由各自不同,都写在测试类的注释里:
+
+| 文件 | 为什么不能逐字节比 |
+|---|---|
+| `cstack.lua` | 每个用例打印的 `final count` 是「一层嵌套吃掉多少 C 栈」,Java 与 C 必然不同 |
+| `gc.lua` | 整篇在测增量收集器的 `count` / `step` / `setpause`,本实现没有这一层 |
+| `literals.lua` | 末尾要 `os.setlocale("pt_BR")` 真的生效,JVM 设不上 |
+| `locals.lua` | `tracegc` 用 `io.stderr:write'.'` 在终结器里写进度点,终结时机由 JVM 定 |
+| `constructs.lua` | `_ENV.GLOB1 = math.random(0, 1)`(第 319 行),两个进程的种子不可能一样 |
+| `sort.lua` | 打印排序耗时与比较次数,而待排数据是 `math.random` 生成的 |
+| `math.lua` | 打印 `random seeds`、随机数的取值范围与耗时 |
+
+#### 本阶段由参考实现裁定的事
+
+* **出错之后 `__close` 里还能 yield。** `lua_pcallk` 在 `yieldable(L)` 时
+ 不建本地恢复点:错误一路冒到 `lua_resume`,由 `precover` →
+ `finishpcallk` → `luaF_close(..., yy = 1)` 收尾,所以每个 `__close` 都能
+ yield,而 `__close` 自己抛的新错误要顶掉旧的、接着传给下一个还没关的
+ 变量。上游 `locals.lua` 那段「yielding inside closing metamethods」里,三个
+ `` 变量依次 yield,中间那个 yield 完再 `error(err + 20)`,于是第三个
+ 看到的错误对象和 `pcall` 最终拿到的是 30 而不是 10。
+ 本实现用 Java 异常代替长跳转,恢复点落在 `pcall` 的 `catch` 里,因此
+ `yy` 得显式传下去:`pcall` 按 `yieldable()` 决定,正好对上
+ `k != NULL && yieldable(L)`。
+* **yield 打断收尾之后,错误对象得有人接着保管。** C 版它一直在栈上
+ (`luaD_seterrorobj` 每关一个变量就重新写一次),`finishpcallk` 从
+ `L->top - 1` 取即可;本实现的错误对象住在异常里,yield 一抛就跟着 Java 栈
+ 没了,只能暂存在 `LuaState.closeErrPending` 上,等下一次 `finishPcallK` 来取。
+ 它必须让位给新错误——否则 `y` 的 `__close` 先 yield 再 `error(30)` 时,
+ `z` 看到的还是旧的 10。
+* **`luaB_error` 必须照 C 的形状写**,连那句 `lua_settop(L, 1)` 都不能省:它决定
+ 「栈顶下面一格是什么」,而 `luaD_seterrorobj` 的 `default` 分支读的就是那格。
+ 少了它,`coroutine.wrap(...)` 抛出的错误会丢掉 `file:line:` 前缀。
+* **`luaL_where` 与 `luaG_addinfo` 对「没有调试信息」给的是相反的答案**。前者要
+ `currentline > 0` 否则返回空串,后者照样打 `?:-1:`。合成一个会同时弄坏
+ `error("x")` 与 `errors.lua` 里那条 `^%?:%-1:` 断言。
+* **`collectgarbage` 要先校验选项**,且 `"generational"` / `"incremental"`
+ 返回的是切换前的模式名(`lbaselib.c: pushmode`),不是整数。
+* **`luaD_shrinkstack` 的 `stackinuse` 取 `top` 与所有 `ci->top` 的最大值**,不是
+ 只看 `top`;`stackErr` 也只在真的缩了栈时才清。
+* **主线程要留一格起手**(`stack_init` 给 `base_ci.func` 预留),`IoLib` 建标准
+ 文件后要 `lua_pop`,`Diff` 要照 `lua.c: docall` 把 `msghandler` 压栈——
+ 三处各差一格时,`errors.lua` 的「expected stack overflow after N calls」
+ 就会偏一。
+
+#### 把 `sort.lua` / `math.lua` 跑完时又挖出来的四个真缺陷
+
+这两个文件原来一进来就崩,修完才跑得到 `OK`。四个都不是「不一致」,是本实现
+写错了:
+
+* **`tunpack` 的越界判断要按无符号比。** C 版 `n` 是 `lua_Unsigned`,
+ `e - i` 溢出时回绕成一个巨大的数;Java 的 `long` 回绕成负数,照有符号
+ 比就漏过去,然后循环从 `minI` 一路 push 到 `maxI`,把 `table.unpack` 撑成
+ `OutOfMemoryError`。
+* **`checkStack` / `checkStackOk` 里的 `top + n` 会整型溢出。** `n` 一下子来个
+ `2^31` 时和变成负数,「超过 `MAXSTACK`」的判断反而失效——同样是一路
+ `OutOfMemoryError`。改成 `long` 算。
+* **`tmove` 的判据不能化简。** C 版是 `e < LUA_MAXINTEGER + f`,不是
+ `e - f < LUA_MAXINTEGER`:两者在无溢出时等价,但这里判的就是溢出。
+ 化简后 `table.move({}, minI, maxI, 1)` 报的是 `#4 (destination wrap around)`,
+ 参考实现报 `#3 (too many elements to move)`。
+* **`luaG_tointerror` 要带 `varinfo` 后缀。** `math.huge << 1` 参考实现给的是
+ `number (field 'huge') has no integer representation`。
+* **`tonumber` 不能用 `String.trim()`。** Java 的 `trim` 去掉的是
+ 「小于等于 U+0020 的字符」,连 `\0` 一起去掉;C 版只认
+ `SPACECHARS`(`" \f\n\r\t\v"`),并且要求解析指针正好走到 `s + l`。于是
+ `tonumber('1\0')` 与 `tonumber('1\0', 2)` 双双被算成 `1`。
+ `b_str2int` 现在整个改在字节上做,并回到长度上判「吃完了没有」。
+
+#### 还有一个:`table.sort` 选枢轴要用无符号算术
+
+`sort.lua` 在冒烟组里(它打印耗时,输出天生不可逐字节比),后来一次全量跑挂在
+`table.sort` 抛出的 `attempt to compare nil with number`。这个文件不播种,
+每次 `math.random()` 给的都是新数据,所以这是个潜伏缺陷:只有当某次分区
+严重失衡、`auxsort` 去换新的随机种子时才会露出来。
+
+* **`IdxT` 是 `unsigned int`,枢轴种子也是。** C 的 `rnd % (r4 * 2)` 是无符号
+ 取模,恒在 `[0, r4*2)`;Java 的 `int` 有符号,而种子恰好就是 C 会生成的那
+ 32 个比特——一半概率为负。有符号取模会把 `p` 拖到 `lo` 以下,极端时拖到 `1`
+ 以下:`a[p]` 是 nil,`sort_comp` 就报
+ `attempt to compare nil with number`。现在用 `Integer.remainderUnsigned`。
+* **`rnd` 是按值传的。** C 里它是 `auxsort` 的形参,递归调用里重新随机不会漏回
+ 外层帧。早先这里用 `int[]` 共享,于是在某个子树深处抽到的种子会一路影响整个
+ 排序剩下的所有分区。
+
+「二十次跑挂一次」是很糟的找 bug 方式,所以 `choosePivot` 现在有一条自己的
+确定性用例(`TableLibTest.choosePivotStaysInsideTheInterval`),直接验 C 版那条
+`lua_assert`——`lo + r4 <= p <= up - r4`——而不再指望五万元素的排序碰巧踩中。
+
+#### 阶段 11:预编译 chunk 要从每一道门进得来
+
+`ldo.c: f_parser` 先读一个字节:是 `LUA_SIGNATURE` 的首字节就走 `luaU_undump`,
+否则才进 `luaY_parser`。就这一步,调用方完全不必声明自己给的是源码还是
+字节码——`load` / `loadfile` / `dofile` / `require` 因此全都天然支持预编译 chunk。
+
+这个分派原先只存在于 `BaseLib` 的 `load` / `loadfile` 里,`lua_load` 本身
+(`LuaState.load`)不看签名,于是 **`require` 一个预编译模块会失败**:
+
+```
+error loading module 'mod' from file '/tmp/mod.lua':
+ /tmp/mod.lua:1: unexpected symbol near '<\27>'
+```
+
+参考实现能加载并正常返回模块。`PackageLib` 走的是 `LuaState.loadFile`,所以这个
+缺陷出现在 `require` 而不是 `load`。修法落在 `lua_load` 那一层,不是给 `require`
+打补丁:
+
+* `LuaState.load` / `loadFile` 现在按 `Lua.LUA_SIGNATURE0` 分派;
+* `loadFile` 仍然先过 `lauxlib` 那段前奏(BOM、`#!`)——预编译 chunk 的首字节
+ 不会是 `#`,所以顺序不能反;
+* 二进制分支不回退首字节:`luaU_undump` 的注释是
+ 「skip 1st char (already read and checked)」,`checkHeader` 从第二个字节比。
+
+`BinaryChunkTest` 两个方向都验:
+
+* **真实 `luac` 编出来的 chunk**(`ReferenceLua.compile`,按 `LUAC54_BIN` 或
+ 参考 `lua` 同目录找),两边跑同一份产物逐字节比。自己编给自己判卷等于没判,
+ 所以产物必须出自参考实现的编译器。语料覆盖 upvalue、变长参数、`goto`、
+ 混合表构造、整数与浮点边界、协程、300 项的常量表,另有两条是故意报错
+ 的——错误消息里带着行号与局部变量名,undump 丢了调试信息就会红;
+* **本实现自己 `string.dump` 出来的 chunk**(`require`、`dofile`、`loadfile`、
+ `mode` 参数、`binary string` 源名规则,以及被截断与被篡改的 chunk——那些必须
+ 是加载期错误而不是崩)。
+
+#### 附:fuzz 语料有 89% 是死的
+
+生成器一直在产出**编译不过**的程序:
+
+* 表构造写成 `{'key0' = v}`。只有**名字**能裸着当键,字符串键必须写成
+ `[k] = v`——数字键分支加了方括号,字符串键分支没有;
+* `expression` 无条件生成 `(select('#', ...))`,而函数只有 40% 的概率声明
+ `...`。非变长函数里写它就是
+ `cannot use '...' outside a vararg function`。
+
+拿参考的 `luac` 量了一下:**200 颗种子里 178 个编不过**。编不过的程序在两边是
+**同一条编译错误**,比对照样「一致」——这道最硬的闸门在几万颗种子上静默地什么
+也没测。修完(键一律加方括号;生成器跟踪「当前函数在生成的是不是变长」)复测:
+0 / 400。
+
+`FuzzTest.assertCompiles` 让这个状态不可能再出现:参考实现的输出只要是编译期
+错误的形状,那颗种子直接判失败。判据是「消息以 `near 'tok'` 收尾」而不是
+「含 `expected`」——运行期错误里到处是 `expected`
+(`bad argument #1 to 'upper' (string expected, got nil)`),而那些是**期望之中**
+的。
+
+#### 顺带把字节码那条路也 fuzz 上
+
+常规 fuzz 走的一直是文本路径,`Dump` / `Undump` 只被几十条手写用例碰过——
+「require 加载不了预编译 chunk」恰好就在这个盲区里。
+`generatedChunksSurviveDumpAndUndump` 补上:把生成的程序体包进变长函数,
+`string.dump` 出来再 `load` 回来跑。于是几千个**随机的** `Proto` 被推过序列化:
+常量表顺序与变长编码、行号表、局部变量与 upvalue 的调试名、`maxstacksize`、
+嵌套 proto。另有一条哨兵输出(`dumped true loaded true`)被断言,
+`string.dump` 静默失败没法再伪装成一致。
+
+#### 参考实现又裁定的五件事
+
+fuzz 生成器学会了 `table.*`,立刻回本:五处不一致,没有一处是手写用例撞得到的。
+
+* **`l_str2int` 的累加器是 `lua_Unsigned`。** `lobject.c` 里那个 `a` 是无符号的,
+ 溢出判据是 `a >= MAXBY10`。用有符号 `long`,`a` 一旦过 2^63 就变负,判据从此
+ 不再触发,溢出就漏了:`1 + (math.mininteger .. 0)` 的操作数是
+ `"-92233720368547758080"`,绕回成精确的 0,于是返回 `1`,而正确行为是落到
+ 浮点路径给 `-9.2233720368548e+19`。改成 `Long.compareUnsigned`。
+* **`luaG_runerror` 是当场加 `file:line:` 的。** `idiv` / `mod` 的除零走
+ `luaG_runerror`,它在抛出点就调 `luaG_addinfo`。我们抛的是裸 `LuaError`,
+ 前缀要等到受保护边界才补上,那时抛出的栈帧已经没了——所以
+ `coroutine.resume` 把错误当值返回时它是没有位置的:
+ `false attempt to divide by zero`,参考实现给的是
+ `false file:line: attempt to divide by zero`。为此 `Arith.rawArith` 现在收一个
+ `LuaState`。
+* **`luaH_newkey` 拒绝 nil 值。** 它的第一行是
+ `if (ttisnil(value)) return; /* do not insert nil values */`。给一个不存在的键
+ 赋 nil,不建节点、也不触发 rehash。而建了节点就会改变算出来的数组部分
+ 大小,那是 `#t` 的直接输入:`table.move({[1]=4.0}, 0, 2, 1)` 之后参考实现
+ 的 `#t` 是 `2`,建了节点就成了 `0`。nil 键与 NaN 键的检查在那一行之前,
+ 所以 `t[nil] = nil` 仍然要报 `table index is nil`。
+* **`lauxlib.c: interror` 判的是 `lua_isnumber`,不是类型。**
+ `luaL_checkinteger` 取不到整数时,若 `lua_isnumber(L, arg)` 成立(能解析成
+ 数字的字符串也算),报 `number has no integer representation`,否则才是
+ `number expected, got string`。于是 `string.format("%d", "3.5")` 是前者
+ (`"3.5"` 解析得成 3.5,只是 3.5 不是整数),
+ `string.format("%d", "abc")` 才是后者。原来的判据写成 `v.isNumber()`,
+ 两条都成了后者。
+* **`%.14g` 只能舍一次,而且要从真值舍。** Java 的
+ `String.format("%.Nf")` 舍的不是真值,而是 `Double.toString` 的最短表示
+ ——等于连舍两次。`math.sqrt(math.maxinteger)` 的真值是
+ `3037000499.976049900054931640625`,最短表示是 `3037000499.97605`,第 15 位
+ 是 5,第二次舍入就进位成 `3037000499.9761`;参考实现给
+ `3037000499.976`。`StringLib` 本来就是用 `BigDecimal` 的
+ (`new BigDecimal(double)` 是精确的二进制展开),`luaO_tostring` 却另写了一份
+ 基于 `String.format` 的。同一个 C 转换有两份实现,正是它们分家的原因,
+ 现在 `Vm.formatG` 直接转调 `StringLib.generalDigits`。
+
+最后一条值得单独说清楚:拿 4602 个浮点实测,旧实现错了 **12** 个(0.26%)。
+手写用例永远撞不到,上游套件全绿也撞不到——是 fuzzer 撞到的。
+`RuntimeTest.floatFormattingMatchesReferenceAcrossMagnitudes` 现在用脚本内部
+的 LCG 生成几千个值钉住它,随机数发生器本身不进比较范围。
+
+#### 把语料拓宽之后又咬出两处
+
+生成器接着学会了 `utf8.*`、`gmatch`、`next`、`string.pack`/`unpack`、
+`table.pack`、非字符串错误值、`math.ult`/`fmod`/`max`/`min`、
+`rawequal`/`rawlen`/`select`。信它之前先量了一遍:每条新分支在 6%–29% 的程序里
+出现,语料确实是活的。立刻又咬出两处,都在 `ltable.c` —— 前五处里也有三处在
+这个文件。
+
+* **`luaH_getn` 是先答 `limit-1`,根本不二分。** 手册只要求 `#t` 返回某个
+ 边界,这让「我们找到了一个合法边界」听起来就够了。不够:真实程序看得见
+ 具体是哪一个。`ltable.c` 为最常见的一串操作(`t[#t] = nil` 之后马上再取长度)
+ 专门留了快路径:
+
+ ```c
+ if (limit > 0 && isempty(&t->array[limit - 1])) {
+ if (limit >= 2 && !isempty(&t->array[limit - 2]))
+ return limit - 1; /* (1a) 不去二分 */
+ else { /* (1b) */ ... binsearch ... }
+ }
+ ```
+
+ 数组 `[1, nil, 3, nil]`:二分给 `1`(它找的是第一个边界),参考实现给
+ `3`。`table.pack(1, nil, 3, nil)` 之后 `#t` 是 `3` 不是 `1`。系统性地铺开各种
+ 空洞(单个空洞到 `n = 32`、空洞对到 `n = 19`、三空洞到 `n = 11`、
+ `table.pack` 的全部 32 种 nil 组合)—— **4968 条结果里错 212 条(4.3%)**。
+
+* **`luaH_resize` 必须允许哈希部分缩成 dummy。** `rehash` 之后 C 的 `reinsert`
+ 用 `luaH_set` 搬旧数据,整数键只要 `<= asize` 就被路由进新数组部分,不占
+ 哈希槽;所以 `luaH_resize(L, t, 4, 0)` 结束时哈希部分是 dummy(0 槽)。
+ 这里早先按「所有搬过去的条目」撑哈希,于是数组 4 配着 2 个用不上的哈希槽。
+ 要命的是 `luaH_newkey` 的判据里有 `isdummy(t)`:哈希不是 dummy 时,下一次插入
+ 直接占空槽、不再 rehash,数组部分就再也长不大。
+ `t = {}; t[2]=2; t[3]=3; t[4]=4; t[6]=6; t[7]=7`,参考实现数组部分 8、
+ `#t` 是 7,这里是 4 和 4。上面那份空洞扫描正是把它挖出来的 —— 旧语料只会按
+ `1..n` 顺序插入,永远走不到这条路。
+
+两处都由 `RuntimeTest.tableLengthBoundaryMatchesReference` 钉住:空洞图案在脚本
+内部用循环铺开(用 LCG 会把随机数发生器本身也拉进比较范围),每张表把
+`#t` 读两次,因为 C 会在第一次调用时顺手把 `alimit` 改成一个提示。
+
+#### 把语料搬进协程里跑,又咬出一处
+
+同一批语料后来也塞进 `coroutine.create` 里跑了一遍,咬出一个跟协程其实没关系的
+bug:
+
+> 协程里的 `pcall(math.random, 5, 1)` 报
+> `bad argument #1 to '?' (interval is empty)`,参考实现报
+> `bad argument #1 to 'math.random' (interval is empty)`。
+
+* **`_LOADED` 是 `global_State` 级的,不是每线程一份。** `lauxlib.c` 的
+ `pushglobalfuncname` 会拿当前函数去 `package.loaded` 里反查两层,把 `'?'`
+ 换成真名:
+
+ ```c
+ static void pushglobalfuncname (lua_State *L, lua_Debug *ar) {
+ ...
+ lua_getfield(L, LUA_REGISTRYINDEX, LUA_LOADED_TABLE);
+ if (findfield(L, L.top - 1, 2)) { ... }
+ ```
+
+ `LUA_LOADED_TABLE` 是 registry 的字段,而 registry 属于 `global_State`
+ —— 所有 `lua_State` 共用同一个。这里早先把 `loaded` 做成了每个 `LuaState`
+ 一张,于是协程里的 `package.loaded` 是空的,协程内的每条诊断都退化成 `'?'`。
+ 修法只有一行:`loaded = main.loaded`。注意它只在协程里现形——主线程
+ 自己会填自己的那张表,所以整个上游套件和此前所有 fuzz 种子对此都一声不吭。
+
+由 `RuntimeTest.libraryFunctionNamesResolveInsideCoroutines` 钉住。
+
+#### 闸门本身有个洞
+
+对 `string.format('%q', ...)` 的系统性扫描(0–255 每个字节、四种位置)是第一个
+往 stdout 里写**非 UTF-8 字节**的探针。引擎本身没查出问题,倒是把探针自己查了
+出来:两边都把捕获到的输出按 UTF-8 解码成 Java `String`。任何非法字节都会变成
+`U+FFFD`,于是 `0x80` 与 `0xEF 0xBF 0xBD` 判成相等:
+
+```
+>>> b'"\x80"\n'.decode('utf-8', 'replace') == b'"\xef\xbf\xbd"\n'.decode('utf-8', 'replace')
+True
+>>> b'"\x80"\n'.decode('latin-1') == b'"\xef\xbf\xbd"\n'.decode('latin-1')
+False
+```
+
+不是推理,是量的:把 `print` 故意改成经 Java `String` 往返一趟(于是每个非
+UTF-8 字节都被写坏),新的 `%q` 扫描在 Latin-1 下红、在 UTF-8 下绿
+—— 旧闸门放行了真实的字节级损坏。闸门现在按 Latin-1 解码(字节↔字符一一对应),
+比对才算真的逐字节。钉子:
+`RuntimeTest.quoteFormatMatchesReferenceForEveryByte`。
+
+#### 把运算符和类型铺成矩阵,又咬出一处
+
+下一个探针是全矩阵:每个运算符 × 每对类型,无元方法,比报错文本,1651 行。
+第二个探针同样铺一遍但带上元方法 —— 两个 handler 谁先走、`__le` 回退成
+`not (b < a)`、`__index`/`__newindex` 的表与函数两种形态、`__concat` 的右结合、
+`__close` 的时机与错误传递,245 行。第二个干净,第一个咬出这一处:
+
+* **`luaG_tointerror` 用的是 `F2Ieq`,不是「取整到整数」。**
+ `lvm.h` 里 `LUA_FLOORN2I` 默认就定义成 `F2Ieq` —— 名字叫 floor,语义是
+ 「只接受整数值」:
+
+ ```c
+ void luaG_tointerror (lua_State *L, const TValue *p1, const TValue *p2) {
+ lua_Integer temp;
+ if (!luaV_tointegerns(p1, &temp, LUA_FLOORN2I))
+ p2 = p1; /* 左边转不成整数就报左边 */
+ luaG_runerror(L, "number%s has no integer representation", varinfo(L, p2));
+ }
+ ```
+
+ 按「取整」的模式判,`1.5` 会被当成能转,于是 `1.5 & 3` 去报右边的
+ `3`:`number (local 'b') has no integer representation`;参考实现报左边
+ `(local 'a')`。矩阵里错 10 行。手写的用例只用 inf/nan 这两个极端值,两种模式
+ 在它们上面结论一样 —— 所以此前每道闸门都放它过去了。
+
+两个矩阵都固化成了用例:`RuntimeTest.operatorTypeErrorsMatchReference` 与
+`RuntimeTest.metamethodDispatchMatchesReference`。
+
+#### 把格式串铺成矩阵,又咬出两处
+
+`lstrlib.c: str_format` 是源码里分支最密的函数之一,所以下一个探针就照着它的每个
+分支铺:22 个动词 × 10 组修饰符 × 5 种宽度 × 9 种精度 × 22 种值类型 ——
+**20777 行**。咬出两处:
+
+* **两道校验的先后。** `str_format` 并不先校验格式串。`d i u o x X` 是
+ 先 `luaL_checkinteger`,`f e E g G` 是先 `luaL_checknumber`:
+
+ ```c
+ case 'd': case 'i': flags = L_FMTFLAGSI; goto intcase;
+ ...
+ intcase: {
+ lua_Integer n = luaL_checkinteger(L, arg);
+ checkformat(L, form, flags, 1);
+ ```
+
+ (`c`、`a`、`s` 才是先校验格式串。)这里一律先校验格式串,于是
+ `("%.100d"):format("x")` 报 `invalid conversion specification`,参考实现报
+ `number expected, got string` —— **错 6620 行**。
+
+* **整数动词给了精度时,`0` 标志失效。** C99:「For `d`, `i`, `o`, `u`, `x`, and
+ `X` conversions, if a precision is specified, the `0` flag is ignored」。
+ 所以 `("%05.3d"):format(42)` 是 ` 042` —— 精度先补成 `042`,宽度再用空格
+ 补,不是 `00042`。错 220 行。浮点动词没有这条规则(`%08.3f` 照旧补零),
+ 所以改动只落在 `emitInt` 里。
+
+两处都由 `RuntimeTest.formatSpecifierMatrixMatchesReference` 钉住:单独回退任何
+一处,该用例就变红。
+
+#### `luaL_checkinteger` 的第二份实现
+
+下一片扫描是 `os.date` 的每个说明符、`os.time` 的参数校验、`utf8.*` 的边界、
+`tonumber` 的 2–36 全进制,854 行。咬出一个老形状的 bug:
+**`luaL_checkinteger` 有两份实现**,`BaseLib` 里那份少两样东西 —— 函数名前缀
+(`bad argument #2 (...)` 而不是 `bad argument #2 to 'tonumber' (...)`),
+以及「无整数表示」那一档(`1.5` 报成了 `number expected, got number`)。
+四个调用点全受影响:`tonumber` 的进制、`select` 的下标、`error` 的层号、
+`ipairs` 迭代器的下标。现在统一转调 `LuaAux.checkInteger`。
+
+同一批扫描还翻出第二个「不是我们错、是 libc 不一致」的地方:
+**`os.date('!%z', t)`**。C 的 `strftime` 读的是进程全局时区,不是 Lua 传给
+它的那个 `tm`;带 `!`(UTC)时两个 libc 答案相反 —— macOS 给本地偏移 `+0800`,
+glibc 给 `+0000`。所以用例只测不带 `!` 的 `%z` / `%Z`:两边同一台机器,
+结果是确定的。
+
+`string.pack` / `unpack` / `packsize` 也整套扫了一遍 —— 全部格式动词(含
+`i`/`I` 的 1–16 字节宽度、`c`/`s` 的计数、`X` 与 `!n` 对齐、四种字节序)×
+各类型的值,外加非法格式串与偏移越界,706 行。这批是干净的,也收成了用例:
+把「已经一致」这件事钉住,否则下次动 pack 那片分支时没有一条用例会说话。
+
+#### 有一处不是分歧:次正规数的 `%a`
+
+`quotefloat` 走的是 C 的 `%a`:
+
+```c
+int nb = lua_number2strx(L, buff, MAX_ITEM, "%" LUA_NUMBER_FRMLEN "a", n);
+```
+
+C99 只规定小数点左边恰好一位十六进制数字,并且「这个数字只在数次正规时为
+0」—— 次正规具体怎么写是 unspecified。glibc 与 Apple libc 选的正好相反:
+
+| 值 | glibc(CI 的 ubuntu) | Apple libc / musl |
+| --- | --- | --- |
+| `5e-324` | `0x0.0000000000001p-1022` | `0x1p-1074` |
+| `3 * 5e-324` | `0x0.0000000000003p-1022` | `0x1.8p-1073` |
+
+这里取 **glibc**:真正作数的闸门(CI)跑在 `ubuntu-latest` 上。
+`StringLibTest.subnormalQuoteFollowsGlibc` 用普通断言在差分表之外钉住它,
+免得有人照 macOS 上的参考实现「修」一把反而把 Linux 上本来对的行为改错。
+(`Double.toHexString` 给出的正是 glibc 那种写法,所以不需额外实现。)
+
+#### 第二处不是分歧:`%a` 带精度的舍入,与 `inf`/`nan` 的零填充
+
+Apple libc 在「`%a` 给了精度、且舍弃的第一位十六进制数字恰为 8」时是错的
+——它根本不是就近舍入,判据成了「第二位舍弃数字的奇偶」:
+
+```text
+("%.4a"):format(0x1.500082p0) -> 0x1.5000p+0 # 0x82 > 0x80,却舍了下去
+("%.4a"):format(0x1.500081p0) -> 0x1.5001p+0 # 这个反倒进上来
+```
+
+glibc 的判据照抄自 `stdio-common/printf_fphex.c` 与 `include/rounding-mode.h`:
+
+```c
+bool more_bits = ((next_digit_value & 7) != 0 || precision + 1 < numend - numstr);
+do_round_away = round_away (negative, last_digit_value & 1,
+ next_digit_value >= 8, more_bits, mode);
+/* FE_TONEAREST: half_bit && (last_digit_odd || more_bits) */
+```
+
+即就近舍入、平局取偶;并且 `precision == 0` 时「保留的最后一位」是小数点
+左边那一位,所以 `("%.0a"):format(1.5)` 是 `0x2p+0` 而不是 `0x1p+0`。
+这批由 `StringLibTest.hexFloatPrecisionRoundingFollowsGlibc` 用普通断言钉住;
+差分网格里跳过所有「舍弃的第一位为 8」的用例(两边跳过的完全一致——判据是用
+不舍入的 `%a` 算的,那个两边一致)。
+
+同一个文件里还藏着一条:**`inf` / `nan` 不参与零填充**。`special` 分支无条件走
+`pad(buf, ' ', width)`,压根不看 `pad == '0'`,所以
+`("%020.5a"):format(math.huge)` 是 17 个空格加 `inf`。
+
+#### 把 `table` 库铺开扫:咬出 `tmove` 少了一个条件
+
+`table` 矩阵 —— `insert`/`remove`/`move`/`concat`/`unpack`/`pack`/`sort` 的
+位置 × 表大小 × 偏移 × 值类型,含同表重叠 `move` 与跨表 `move`,**6277 行** ——
+跑出 268 行分歧,而且每一行都是同一个让人心里发毛的形状:**表内容完全一样,
+只有 `#t` 不一样**。
+
+```text
+< MV2-dump #0 -2=nil,-1=nil,0=nil,1=nil,2=1,3=nil,4=nil,...
+> MV2-dump #2 -2=nil,-1=nil,0=nil,1=nil,2=1,3=nil,4=nil,...
+```
+
+根因是漏了一个条件。C 判拷贝方向是这样写的:
+
+```c
+if (t > e || t <= f || (tt != 1 && !lua_compare(L, 1, tt, LUA_OPEQ))) {
+ for (i = 0; i < n; i++) { ... } /* 正向 */
+} else {
+ for (i = n - 1; i >= 0; i--) { ... } /* 逆向 */
+}
+```
+
+这份实现只写了前两项。第三项跟位置毫无关系:**源和目标是两张不同的表时
+一律正向**,与 `t`、`f` 谁大谁小无关。漏掉它,本该正向的情形会走成逆向。
+
+次序不改变表里最终有什么,但改变布局,而 `#t` 读的就是布局。
+`table.move({1}, -2, 1, -1, {1})` 之后目标表同样只剩 `[2]=1`:正向先把 `[1]`
+挖空,`alimit` 仍是 1,于是 `luaH_getn` 的第一条分支(「`array[alimit-1]` 为
+nil 就只在数组部分二分」)成立,答案是 `0`;逆向却先塞 `[2]`,把 `alimit` 顶到
+2,那条分支不再成立,答案变成 `2`。于是就有了那个绕人的现象:同样的写入序列,
+走 `rawset` 一致、走 `table.move` 不一致——差的只是内部布局。
+
+两条经验落进了测试:
+
+* **光比表内容不够,必须连 `#t` 一起比**,否则布局类的错永远看不见。
+* 这一片现在是三个常驻测试:
+ `RuntimeTest.tableLibraryMatrixMatchesReference`(6277 行,就是它咬出来的)、
+ `RuntimeTest.patternMatchingMatrixMatchesReference`(模式匹配 × 主题 ×
+ `init` × 替换形态,24819 行,**干净**,照样钉住,照第 25 条经验:已经一致的
+ 也得有人替它说话)、
+ `RuntimeTest.tableLengthAcrossInsertionHistoriesMatchesReference`
+ (3736 行,把插入历史本身当成扫描维度:`1..n` 的全部子集 × 三种插入
+ 次序、先填满再挖空、`move` 造出的布局、`insert`/`remove` 交替、负键与 0 键、
+ 二分上到 40、尾部截断——也干净)。
+
+把那一个条件回退,第一个测试立刻变红。
+
+#### `debug` / `load` / `require` 矩阵:咬出 `xpcall` 少了参数校验
+
+下一片:`debug.getinfo` 的 14 个字段 × 0–6 层、`getlocal`/`setlocal`、
+upvalue 全家、`traceback`(含协程内)、`sethook` 的 10 种掩码加计数钩子、
+`load` 的源码/读取函数/模式/env/二进制块、`package.path`/`cpath`/`config`/
+`searchers` 与 `require` 的几种怪角、`error` 的 0–4 层、`xpcall`、`assert`,
+以及错误在协程里的传播,289 行。咬出一处:**`xpcall` 把参数校验丢了**。
+
+```c
+static int luaB_xpcall (lua_State *L) {
+ ...
+ luaL_checktype(L, 2, LUA_TFUNCTION); /* check error function */
+```
+
+C 在调用任何东西之前就先校验消息处理函数必须是函数。这份实现只判了
+「参数够不够两个」,于是 `xpcall(f, 5)` 不报错,照常调 `f`,`f` 抛错后拿
+`5` 当处理函数:
+
+| | 参考实现 | 修之前的本实现 |
+| --- | --- | --- |
+| `xpcall(f, 5)` | `false bad argument #2 to 'xpcall' (function expected, got number)` | `true false error in error handling` |
+| `xpcall()` | `false bad argument #2 to 'xpcall' (function expected, got no value)` | `false bad argument #2 to 'xpcall' (value expected)` |
+
+顺带把 `io` 矩阵也铺了:32 种打开模式(含全部非法模式)、`read` 的全部格式与
+0–24 字节计数、数字读法、`seek`、`lines`、关闭后再用、
+`io.input/output/read/write/close`、追加模式的 `seek` 语义、`r+` 交错、
+5000 字节大块、同一文件开两次,167 行,干净。它仍值得留着:只有它碰得到
+**errno 那一层** —— `io.open` 失败要返回 `"路径: No such file or directory"`
+加错误号 `2`,C 那是 `strerror` 给的,我们得自己对齐。
+
+#### 闸门自己又有个洞:回溯把输出静默截断了
+
+`debug` 矩阵第一版是绿的 —— 而 `xpcall` 那个 bug 还在代码里。这是闸门
+失灵,不是代码过关。
+
+`Diff.assertMatches` 的归一化会把第一个 `stack traceback:` 之后的全部内容
+砍掉。对「脚本自己崩了」的情形这是有意的:消息在前、回溯在后,回溯本来
+就没法逐行比。但当回溯是脚本打印出来的(`debug.traceback()` 的返回值),
+它就落在中间,后面脚本打印的东西全被丢掉 —— 这片脚本第 5 段打印了回溯,
+后面还有 180 行。
+
+现在加了门禁:回溯块之后只要出现「不以制表符开头的非空行」(回溯自己的每行
+都以制表符开头),就直接判失败,并提示改用不剥回溯的 `assertFullMatches`。
+既有 13 个用例的输出里带回溯,收紧后的判据(看的是回溯之后有没有内容,
+而不是「有没有回溯」)把它们全放过了 —— 那些用例里回溯确实在最后。
+
+这是第二次发现「闸门瞎了、代码没错」(第一次是捕获输出按 UTF-8 解码)。
+一个悄悄比得比它声称的少的闸门,比没有闸门更糟:它报绿。
+
+#### fuzz 咬出的一处:错的是驱动,不是引擎
+
+放到 16500 个程序上跑,fuzzer 产出一个程序,栈溢出时参考实现报第 67 行、
+我们报第 65 行。引擎没错,是驱动少占了一格栈:
+
+```c
+ lua_pushcfunction(L, &pmain); /* to call 'pmain' in protected mode */
+ lua_pushinteger(L, argc); /* 1st argument */
+ lua_pushlightuserdata(L, argv); /* 2nd argument */
+ status = lua_pcall(L, 2, 1, 0); /* do the call */
+```
+
+`lua.c` 的 `main` 给 `pmain` 传两个实参,栈的初始 `top` 是 3;
+`Diff.runLikeLuaC` 只传了一个。少一格会一路带到底:递归多走一层,
+最深那一帧就换了个函数,报错行号也就跟着换。
+
+难查在于它不是每次都差:每帧开销是 `k+1`(`k` 为局部变量个数),
+深度是 `floor((limit - base) / (k+1))`,基址差 1 只在刚好跨过整除边界时
+才改变结果。按帧大小量下来:
+
+| 局部变量 `k` | 0 | 1 | 2 | 3 | 4 | 6 | 8 | 16 | 32 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| 修之前是否差一层 | 是 | 是 | 是 | 否 | 否 | 是 | 否 | 否 | 否 |
+
+只挑一个例子试会以为没问题。补上那一格之后九个全对,现在由
+`RuntimeTest.stackOverflowDepthAndLineMatchReference` 铺开盯着。
+
+(那片探针还有个陷阱:`local function f() return f() end` 是尾调用,
+不占栈,会变成死循环而不是溢出 —— 参考实现实测挂满 5 分钟才被杀。
+所以探针里一律写成 `return 1 + f()` 这种非尾形式。)
+
+#### 三片扫下来是干净的
+
+不是每片都能咬出东西。`math` 全函数 × 30 个边界值(含 ±inf、nan、-0.0、
+`maxinteger`、`mininteger`、`2^53`、`2^63`、`1/3`)、`modf` 的两个返回值、
+`log` 的底、每个函数六类参数错误、播种后的随机序列,加上 `os` 里输出确定的
+那部分,共 **1423 行**,干净。照样留在套件里,理由三条:
+
+* **浮点的最后一比特**:`math.sin` 这些走的是 `libm`,`Math.sin` 与之未必
+ 逐比特一致。默认 `tostring` 只打 14 位有效数字,正好把它盖住 —— 这片证的
+ 是「确实盖得住」,而不是「从来没看过」。
+* **`math.random` 的序列**:xoshiro256** 把它定死了,播种后必须逐个对上。
+* **`errno` / `strerror` 那一层**,在 `os.remove` / `os.rename` 上再碰一次。
+
+其中有三处根本没法测:`os.clock`(耗时)、无参 `math.randomseed`
+(拿时钟播种)、无参 `os.time`(当前时间)。
+
+#### `string` 库矩阵:咬出两条,外加一处参考实现自己都对不上的
+
+`string` 库非模式匹配的那一半 —— `sub` / `byte` / `char` / `rep` /
+`upper` / `lower` / `len` / `reverse` / `format` / `tonumber(base)` /
+`tostring`(模式匹配的 pattern 语义另有 24819 行那片单独覆盖)——
+铺开 **3921 行**,咬出两条:
+
+**其一,`__name` 走的是 C 字符串语义。**
+
+```c
+ int tt = luaL_getmetafield(L, idx, "__name");
+ const char *kind = (tt == LUA_TSTRING) ? lua_tostring(L, -1) : ...;
+ lua_pushfstring(L, "%s: %p", kind, lua_topointer(L, idx));
+```
+
+`lua_tostring` 交出来的是 `const char *`,`"%s"` 又按 C 字符串处理,于是名字
+在第一个 `\0` 处被悄悄砍断:`__name = "A\0B"`,参考实现只打印 `A`,我们把整个
+`"A\0B"` 都打了出去。Lua 字符串不以 NUL 结尾、C 字符串以 NUL 结尾,这一局是
+后者赢。修法是 `LuaString.toCString()`,专供「C 版当 `const char *` 用」的那些
+地方。
+
+**其二,`%p` 不补前导零。** 地址写成了 `0x%016x`,参考实现是 `0x7ab30002c0`
+这种不补零的样子(macOS 与 glibc 都不补)。
+
+**再有一处是两边根本不能比的**:`"+"` / 空格标志作用在 NaN 上时,macOS 的 libc
+不打符号(`nan`),glibc 打(`+nan`)。CI 的 conformance job 在 ubuntu 上跑、
+本机在 macOS 上跑,于是**参考实现自己**两次跑出来就不一样。脚本里先把 NaN 的
+符号抹掉再比,实现这边保持与 glibc 一致。
+
+#### 顺带发现:参考实现有一处是不确定的
+
+`math` 矩阵第一次跑红了,报的是 `math.atan` 的报错名对不上。查下去才发现
+`atan` 与 `atan2` 在 `lmathlib.c` 里是同一个 `math_atan`:
+
+```c
+ {"atan", math_atan},
+ ...
+#if defined(LUA_COMPAT_MATHLIB)
+ {"atan2", math_atan},
+```
+
+报错里的函数名由 `lauxlib.c: findfield` 遍历 `package.loaded` 时**先撞见谁**
+决定,而那个顺序由字符串哈希决定,哈希又被 `luai_makeseed`(按时间)播种:
+
+```
+$ for i in 1 2 3; do lua -e 'print(pcall(math.atan))'; done
+false bad argument #1 to 'math.atan2' (number expected, got no value)
+false bad argument #1 to 'math.atan2' (number expected, got no value)
+false bad argument #1 to 'math.atan' (number expected, got no value)
+```
+
+八次里六次 `atan2`、两次 `atan`。**参考实现不确定,我们确定**(哈希不按时间
+播种),这是有意保留的偏离。把 `math` / `string` / `table` / `os` / `io` /
+`coroutine` / `debug` 七个库所有函数的零参报错名各跑八遍扫过,
+`atan` 是唯一会变的一个;脚本里统一成 `atan` 再比。
+
+#### 元方法与「参数缺失」矩阵:同一个根因咬出七处
+
+元方法语义 × 变量来源推断 × 参数缺失,**2025 行**。前两片是干净的:
+19 个二元操作符 × 9 类值、`-` / `#` / `~` / `not`,以及
+`global` / `local` / `upvalue` / `field` / `method` / `constant` / 变长参数
+这七种上下文里同一句报错带的不同后缀 —— 那部分本来就对。
+
+咬出的全在第三片:六十来个库函数各用「不传 / `nil` / 数字 / 字符串 / 表 /
+布尔」调一遍。根子就一个 —— **缺的参数读到的是残值**。
+
+```java
+public LuaValue arg(int i) {
+ return stack[ci.func + 1 + i]; // 越界?读到的是上一帧留下的东西
+}
+```
+
+C 里 `lua_type` 对越界下标返回 `LUA_TNONE`、取值拿到的是 `nil`;我们的栈是
+**复用的数组**,越界读出来的是上一帧留下的东西。于是:
+
+| | 参考实现 | 修之前 |
+| --- | --- | --- |
+| `pcall(setmetatable)` | `(table expected, got no value)` | `got function` / `got nil`(看那一刻槽里是什么) |
+| `assert()` | `false bad argument #1 to 'assert' (value expected)` | `true`(残值为真 → 返回 0 个值) |
+| `ipairs()` | `(value expected)` | `true function 0 0` |
+
+**同一个用例这次绿、下次红**——这类最难查。改成越界返回 `nil` 之后才谈得上
+「缺参」到底是什么语义,接着就是七处:
+
+1. **缺参报 `no value`,不是 `nil`**:`rawlen` / `rawequal` / `rawget` /
+ `rawset` / `setmetatable` / 五个 `coroutine.*`,全军覆没。C 的
+ `luaL_typename` 对越界下标给 `no value`。
+2. **`rawequal` 漏了两遍 `luaL_checkany`**:`rawequal(1)` 本该报
+ `bad argument #2 ... (value expected)`,我们返回 `true true`。
+3. **`pairs` / `ipairs` 不要求是表**:C 只做 `luaL_checkany`,
+ `pairs(5)` 照样返回三个值,真正炸是在迭代时 `next(5)` 报
+ `(table expected, got number)`。我们多加了「必须是表」,把它变成报错。
+4. **`select` 的 `#` 只比第一个字节**:C 是
+ `*lua_tostring(L, 1) == '#'`,所以 `select("#abc", 1)` 也是「数个数」,
+ 返回 1;判断整串等于 `"#"` 就漏了。另外缺参报的是
+ `number expected, got no value`,不是 `value expected`。
+5. **`rawget` 没有「键不能是 nil」那条**:`rawget({}, nil)` 是 `true nil`
+ (nil 键落到 `ltable.c` 的 dummynode)。报「table index is nil」的是
+ `rawset`,那条来自 `luaH_newkey`。
+6. **`setmetatable` 的第二个参数**走 `luaL_argexpected`,必须带
+ `got <实际类型>`;而且缺参是 `TNONE`,不属于 {nil, table},所以
+ `setmetatable({})` 要报错 —— 我们把它当合法放过去了。
+7. **`next` 的第二个参数不给就补一个**,不是报错(C 的 `lua_settop(L, 2)`)。
+
+(顺带:`__index` 链、`__eq` / `__lt` 的回退、`__metatable` 保护、元方法自身
+报错、把带 `__call` 的表当元方法用 —— 那些一行不差。)
+
+#### 可选参数矩阵:两处「校验顺序写反」
+
+上一片只试了「0 个 / 1 个实参」,这一片把第 2、第 3 个参数「给不给、给什么」
+也做全排列:七十来个函数,按 `{nil, 1, "x"}` 的所有组合从 0 个实参一直喂到
+各函数的形参上限,**1866 行**。咬出两条,都是同一个毛病 —— **多道校验的先后
+写反了**:
+
+**其一,`string.gsub`。**
+
+```c
+ int tr = lua_type(L, 3); /* 只记类型,不校验 */
+ lua_Integer max_s = luaL_optinteger(L, 4, srcl + 1);
+ int anchor = (*p == '^');
+ lua_Integer n = 0;
+ int changed = 0;
+ ...
+ luaL_argexpected(L, tr == LUA_TNUMBER || tr == LUA_TSTRING ||
+ tr == LUA_TFUNCTION || tr == LUA_TTABLE, 3,
+ "string/function/table");
+```
+
+第 4 个参数的校验在前面。于是 `gsub(s, p, nil, "x")` 报的是
+`bad argument #4 (number expected, got string)`,而我们因为先校验第 3 个参数,
+报成了 `bad argument #3 (string/function/table expected, got nil)`。
+
+**其二,`tonumber`。** 三道校验的顺序是
+`luaL_checkinteger(2)` → `luaL_checktype(1, LUA_TSTRING)` →
+`luaL_argcheck(2 <= base <= 36, 2, "base out of range")`,**范围检查在最后**:
+
+| | 参考实现 | 修之前 |
+| --- | --- | --- |
+| `tonumber(nil, 1)` | `bad argument #1 (string expected, got nil)` | `bad argument #2 (base out of range)` |
+| `tonumber(1, 1)` | `bad argument #1 (string expected, got number)` | `bad argument #2 (base out of range)` |
+
+三个函数不在这张表里:`os.time` / `os.date` 第一个参数为 `nil` 时读的是时钟,
+`math.randomseed(nil)` 拿时钟播种,`io.lines` 无参会去读 stdin。
+
+#### `package` / `require` / `load` 矩阵:两处 bug,外加一个必须共用的计数器
+
+**311 行**:`package` 的静态结构、`searchpath`、`require` 的各种形态(模块返回
+`nil` / `false` / `true` / 什么都不返回、缓存语义、模块里抛错、循环依赖、
+`preload` 压过磁盘、模块名带斜杠),以及 `load` 的 `mode` / `env` /
+`chunkname` / 读函数。
+
+**其一,`package.loadlib` 的第三个返回值说的是「在哪一步失败」,不是「失败」。**
+`ERRLIB`(库打不开)给 `"open"`,`ERRFUNC`(库里没这个符号)给 `"absent"`:
+
+```c
+ lua_pushliteral(L, (stat == ERRLIB) ? "open" : "absent");
+```
+
+我们照着「没有 dlopen 的兜底分支」写死成 `"absent"` —— 可那个分支下
+`lookforfunc` 恒返回 `ERRLIB`,照样该给 `"open"`。用第三个值决定「还找不找」
+的代码会因此走错分支。
+
+**其二,循环依赖报的不是裸的 `C stack overflow`。** 参考实现给的是:
+
+```
+error loading module '_pk_b' from file './_pk_b.lua':
+ C stack overflow
+```
+
+因为语法分析器与 `luaD_call` 共用同一个计数器:
+`#define enterlevel(ls) luaE_incCstack(ls->L)`。第 200 层不是在 `lua_call` 上
+撞到的,而是在某次 `luaL_loadfile` 的解析中途撞到的 —— 错误被
+`luaD_protectedparser` 兜住,`loadfile` 返回非 `LUA_OK`,最后由 `checkload`
+包上那层前缀。调用深度与表达式嵌套深度各数各的,就只能拿到裸消息。
+连模块名都钉在用例里了。
+
+修它顺带咬出计数器的另一半 —— **回滚**。`luaD_call` 的自增写在
+`try` 等价物之前,`luaE_checkcstack` 抛错时 `finally` 管不到那一下;
+C 靠 `luaD_rawrunprotected` 末尾的 `L->nCcalls = oldnCcalls`(在 `pcall` 边界)
+把账补回来,Java 没有 longjmp 可退。不补的话每次 C 栈溢出永久漏一层:
+用 `pcall` 递归探针量出来是 197 → 196 → 195 → 194 → 193,上游 `errors.lua`
+于是开始报 `error in error handling`,而参考实现那里是 `OK`。解析器那头同理,
+回滚点必须挂在 `lua_load` 上,挂低了(挂在二进制 / 文本分派处)一次失败的
+`load` 就漏两层。
+
+**还有一处根本没法比**:`package.loadlib` 的失败**消息**来自平台 libc
+(macOS 是 `dlopen(...)`,glibc 是 `cannot open shared object file...`),
+本实现压根不加载动态库 —— 三边三种文本,只能比「它是不是字符串」。
+
+#### `io` 库深矩阵:`fseek` 欠你一个 `EINVAL`
+
+`io` 原本是十个标准库里最薄的一片(167 行),这一片补上 **124 行输出**:
+`open` 的十六种合法模式与九种非法模式(非法模式是**抛错**不是返 `nil`+消息,
+`l_checkmode` 的判据是 `[rwa]%+?b?`)、`read` 的全部格式、`write` 写整数与
+浮点的形态差异、`seek` 三个起点与越界、`lines`、`setvbuf`、关两次、
+`io.output` / `io.input` 切换,以及含 `\0` 与非法 UTF-8 的二进制安全。
+
+咬出一处:**负的目标位置是 `EINVAL`,不是笼统的 IO 错**。
+
+```
+参考实现: nil Invalid argument 22 -- seek("set", -1)
+修之前: nil Input/output error 5
+```
+
+`fseek` 对三种起点(`SET` / `CUR` / `END`)算出来的负偏移**一律**给 `EINVAL`,
+而 Java 的 `RandomAccessFile.seek` 抛的是 "Negative seek offset",掉进
+`errnoOf` 的兜底分支就成了 `EIO`(5)。往 EOF **之后**定位是合法的(返回新位置,
+不报错)—— 修这条时别顺手一起拦了。
+
+一处刻意不测:`print` 写的是 C 层的 `stdout`,不受 `io.output` 切换影响 ——
+假设它会跟着变的脚本,两边会各自给出一个错的答案。
+
+`fopen` 一个**目录**原本也写在这一节里,那是错的:它是一条完整的 POSIX 规则
+(`open(dir, O_RDONLY)` 成功、`O_WRONLY` 给 `EISDIR`、第一次读失败于 `EISDIR`),
+macOS 与 glibc 一样,只是 JVM 不愿意建模。现在建模了,见下文 *目录那个 bug*。
+真正不能比的只有目录上的 `seek("end")`:给的是目录自身的大小(本机 64),
+那是文件系统的事实,不是 Lua 的事实。
+
+#### `os.time` 那半边:`lua_tointegerx` 是带字符串转换的
+
+既有那片 854 行铺的是 `os.date` 的每个转换说明符;这一片铺它没盖住的半边:
+`os.time` 的各种表形态(非闰年进位、月/日/时越界、缺省值、被忽略的多余字段)、
+`difftime`、`getenv`、`remove` / `rename`、`tmpname`、`execute` 的退出码、
+`setlocale`。
+
+**日期表的字段是用 `lua_tointegerx` 读的,它会转换字符串**:
+
+```c
+ lua_Integer res = lua_tointegerx(L, -1, &isnum); /* cvt2num:字符串也走 */
+```
+
+所以 `os.time{year = "2000", month = 1, day = 1}` **就是** 2000 年,而我们报
+`field 'year' is not an integer`。库函数参数走的是会做类型转换的 `luaL_check*`
+那一套,VM 的算术指令则刻意不转换 —— 两条相反的规矩,很容易写成同一条。
+
+顺带钉住:`month` / `day` 是**必填**(缺了报 `field 'month' missing in date
+table`,`year` 有缺省、`hour` 缺省 12);字段大到越出 int 范围报
+`field 'x' is out-of-bound`;`yday` / `wday` 之类的多余字段被忽略。
+
+一处刻意不测:`year = -100` 这种极端年份(`mktime` 的可表示范围随 libc 而定,
+macOS 报 `time result cannot be represented in this installation`)。
+
+删**目录**原本也写在这里,理由是「`ENOTEMPTY` 的数值 macOS 是 66、glibc 是 39」
+—— 但「数值随系统变」是**按系统取值**的理由,不是跳过这条用例的理由。现在比了,
+`ENOTEMPTY` 按 `os.name` 取,见下文 *errno 形状表*。而且当初跳过它,正好把
+「errno 写死成 39、在哪儿都不对」这个错盖住了。
+
+#### 弱表 / `__gc` / `` 矩阵:`isdecGCmodegen`
+
+这是整个项目**语义风险最大**的一块 —— **没有收集器**(`lgc.c` 不移植),弱表靠
+`WeakReference`、`__gc` 靠 `Cleaner` 重建,光靠 `GcTest` 那 12 个手写用例不够。
+这一片铺:弱键 / 弱值 / 双弱、仍被强引用着的键、互相引用的弱值、根本不可收集的
+值(字符串 / 数字 / 布尔)、`__mode` 的十种写法、`__gc` 一个与二十个、`__gc`
+里抛错、`setmetatable` **之后**才加的 `__gc`(C 不认,只在设元表那一刻检查)、
+`` 的关闭顺序与 `err` 参数。
+
+咬出一处:**`collectgarbage("generational"/"incremental")` 报的是
+`isdecGCmodegen`,不是 `gckind` 本身**:
+
+```c
+#define isdecGCmodegen(g) (g->gckind == KGC_GEN || g->lastatomic != 0)
+```
+
+`lastatomic` 在**头一次真正收集之后**就非 0,而加载脚本本身就要分配 —— 用户代码
+刚跑起来时它已经不是 0,于是尽管 `gckind` 是 `KGC_INC`,看起来却是分代模式。
+参考实现连续切换五步:
+
+```
+1 generational 2 incremental 3 incremental 4 generational 5 incremental
+```
+
+只记 `gckind` 的话**第一行就假**。两种切换都会把 `lastatomic` 清 0,之后就只看
+`gckind`。
+
+两处不可比:`collectgarbage("count")` 的数值(没有收集器,无从统计)与
+`collectgarbage("step")` 的返回值(参考实现的一「步」永远走不完一轮、恒
+`false`;我们没有增量收集器,一步就是一整轮、恒 `true`。**刻意**取 `true`:
+取 `false` 会让 `repeat until collectgarbage("step")` 这类写法死循环)。
+
+#### `debug` × 协程 交叉矩阵:`_LOADED` 得挂在注册表上
+
+两边各自都铺过(114 / 99 行),没铺的是**交叉**那一层:给协程做 `getinfo` /
+`getlocal` / `traceback` / `sethook`、钩子的**计数**(打印成一个数而不是一条一行
+—— 计数本身就是对行号表与调用序列的强校验,`tail call` 事件也钉在里面)、
+registry / `uservalue` / `setmetatable`,以及协程状态机的补充。
+
+咬出一处:**`_LOADED` 必须是注册表的一个字段**:
+
+```c
+#define LUA_LOADED_TABLE "_LOADED" /* lauxlib.h */
+```
+
+`package.loaded` 不是「别处存一份、注册表里再放一份」,它**就是**那个注册表字段,
+所以 `debug.getregistry()["_LOADED"]` 得拿得到。我们只在 `LuaState` 里存了一份,
+于是 `type(debug.getregistry()["_LOADED"])` 给 `nil`,参考实现给 `table` —— 靠
+注册表做手脚的脚本会直接失效。改成从注册表里读回来:脚本把 `_LOADED` 换掉时
+我们也跟着换。
+
+#### 错误矩阵(208 行):`__name` 上的两处
+
+一条此前没人铺过的轴:`error(msg, level)` 的 level 从 `-1` 一路到
+`math.maxinteger`、各种类型的**错误对象**(以及 `print` / `tostring` /
+`string.format("%s")` 各自怎么处置它)、`luaL_where` 的位置前缀在**每一个可能
+抛错的语法位置**上的取值(表构造、数值与泛型 `for`、十二个元方法、`gsub` 的替换
+函数、`sort` 的比较器、协程、``、方法、`__index` 链、尾调用、`load` 出来的
+chunk 配 `=` / `@` / 字符串三种名字)、`xpcall` 的形状、钩子里抛错、以及 `strip`
+过的 chunk。208 行一致。
+
+咬出两处,都在 `luaT_objtypename` 上:
+
+```c
+const char *luaT_objtypename (lua_State *L, const TValue *o) {
+ Table *mt;
+ if ((ttistable(o) && (mt = hvalue(o)->metatable) != NULL) ||
+ (ttisfulluserdata(o) && (mt = uvalue(o)->metatable) != NULL)) {
+ const TValue *name = luaH_getshortstr(mt, luaS_new(L, "__name"));
+ if (ttisstring(name)) /* is '__name' a string? */
+ return getstr(tsvalue(name)); /* use it as type name */
+ }
+ return ttypename(ttype(o)); /* else use standard type name */
+}
+```
+
+**`__name` 是 C 字符串**:`getstr()` 交出来的是 `const char *`,`luaG_typeerror`
+用 `%s` 拼,于是在第一个 `\0` 处被砍断 —— `__name = "A\0B"` 报的是
+`attempt to perform arithmetic on a A value`,只有 `A`。我们把整个字节数组都
+交出去了。(`luaL_tolstring` 那条路上同一个坑早就修过,这是它的第二份拷贝。)
+
+**只有表与(完整)userdata 认 `__name`**:其余类型**连类型元表都不看**,直接走
+`ttypename`。所以 `debug.setmetatable(nil, {__name = "NilT"})` 之后 `nil + 1`
+参考实现报的仍是 `... on a nil value`。我们的查找走的是 `luaT_gettmbyobj`,它会
+把**类型元表**也算进来,于是多报了 `NilT`。
+
+另有四条钉住了、但并不是 bug 的:
+
+* **`debug.traceback` 把非字符串、非 `nil` 的消息原样返回**(`ldblib.c:
+ db_traceback`),所以 `xpcall(f, debug.traceback)` 碰上表错误对象返回的是那张
+ **表**,不是回溯。而 `lua_tostring` **不**认 `__tostring` —— 这正是 `lua.c` 的
+ `msghandler` 要显式 `luaL_callmeta(L, 1, "__tostring")` 的原因。
+* **行钩子在这一行的第一条指令之前触发**,所以 `print(pcall(f))` 与钩子写在
+ 同一行是**拦不住**的;调用钩子连 `print` / `pcall` 自己都算。
+* **`xpcall` 会丢掉处理器的第二个返回值**:`ldo.c: dothecall` 是
+ `luaD_callnoyield(L, L->top - 2, 1)`,结果数写死 1。
+* `coroutine.wrap` 抛出的错带**两层**位置前缀;`pcall(error, "x")` **没有**前缀
+ (C 函数没有行号);库函数报错**有**,因为 `luaL_error` 的 `luaL_where(L, 1)`
+ 取的是调用者那一层。
+
+#### 内嵌 `\0` 矩阵(88 行):C 在十三个门口各砍一刀
+
+一条轴、一条规则:Lua 字符串是字节串,但 C 里凡是接 `const char *` 的 API ——
+`strlen` / `%s` / `strstr` / `strchr` / `strcmp` —— 都在第一个 NUL 处停。88 行,
+咬出**十三处**分歧,全是同一个 bug 在十三个不同门口:
+
+| 在第一个 `\0` 处砍断 | chunk 名(`luaS_new`)、模块名与 `package.loaded` 的键、`searchpath` 四个参数、`io.open` / `io.lines` / `os.remove` / `os.rename` / `os.getenv` 的文件名、`fopen` 的**模式**、`debug.getinfo` 的 `what`、`debug.sethook` 的 mask、`luaL_traceback` 的消息、`luaL_checkoption`(`collectgarbage`)、`string.pack` 的格式串、`__name` |
+| --- | --- |
+| **不**砍断 | `string.format("%s")`(走 `luaL_tolstring`,带长度)、`error` 的消息(`lua_concat`)、`os.date`(按 `s + slen` 扫完整个格式串)、表键、`gsub`、`upper` |
+| 是**拒绝**而不是砍断 | 带修饰符的 `%s` 与 `string.pack` 的 `z`:`luaL_argcheck(L, l == strlen(s), arg, "string contains zeros")` |
+
+其中两处值得单独说:
+
+**`os.remove("/x\0y")` 直接把进程干掉了。** `Paths.get` 抛
+`InvalidPathException`,它是 `RuntimeException`、**不是 `LuaError`**,而
+`pcall` 只拦 `LuaError`,于是整颗逃逸出去。按 C 字符串语义截断之后这类根本
+不会出现了,`catch` 顺手也把 `RuntimeException` 兜住。
+
+**`__name` 是它的第二份拷贝。** `luaL_typeerror` 与 `luaG_typeerror` 都走
+`luaT_objtypename`,但 `typeArgError` 自己又写了一份
+`metafieldOf(v, "__name")` —— 于是两条规则(截断、只认表与 userdata)**两条都
+错**。现在跟另外两处一样调 `LuaTm.objTypeName`。
+
+两个 `debug` 上的最阴:`debug.getinfo(f, "S\0ln")` **不该**报 `invalid option`
+(C 扫到 NUL 就收工,`l` 和 `n` 它根本没看见);`debug.sethook(f, "l\0c")`
+只装行钩子,`"c\0l"` 只装调用钩子 —— 两者的计数正好反过来。
+
+#### 内嵌 `\0` 第二轮(40 行):`load` / `loadfile` / `io.output`
+
+把 `src/main` 里剩下的 `.toJavaString()` 逐个过一遍,又揪出三个门口:
+`load` 的**模式**(`checkmode` 比的是 `const char*`)、`loadfile` 的文件名、
+`io.output` / `io.input` 拿到的文件名。于是 `load(src, name, "t\0b")` 的模式是
+`"t"`,而 `load(src, name, "b\0t")` 会去 undump 一个文本 chunk、报
+`attempt to load a text chunk (mode is 'b')`。
+
+还咬出一处 C 与 Java **判路径判得不一样**的地方:**`loadfile("")`**。C 的
+`fopen("")` 给 `ENOENT`,报 `cannot open : No such file or directory`;Java 把
+空路径解析成当前目录、失败在读,给 `Is a directory`,我们于是报成了
+`Input/output error`。空路径是唯一一处,按 C 的判据单独取 `ENOENT`。
+目录原本在同一句里被断言成「不用改、也不该改」—— 而这句是错的,值得留着当教训:
+它是**读 lauxlib.c 读出来的,不是跑参考实现跑出来的**。C 里 `loadfile("/tmp")`
+确实是 `fopen` 成功、失败在读,但本实现报的是
+`cannot open /tmp: Input/output error`(Java 的 `Files.readAllBytes` 在开就抛了),
+也就是另一条分支。量过之后,一句错话变成五个门口的真 bug,
+见下文 *目录那个 bug*。
+
+#### 一片铺下来是干净的:`load` 的读取器契约(54 行)
+
+`lua_load` 的 `lua_Reader` 此前没人铺:一次给完 / 一个字节一个字节给 / 任意切分、
+**空串不是结束**(`Zio` 会继续问下一个,只有 `nil` 才收尾,所以
+`{"", "", src}` 编出来是 `src`,而 `{"", ""}` 编出来是**空 chunk**)、读取器
+返回 `true` / 表 / 函数就当结束、读取器抛错要原样传出去(哪怕已经喂进去一半)、
+读取器里**不能 yield**(`lua_load` 走 `luaD_callnoyield`)、以及 `load` **不像
+`loadfile` 那样吃 BOM 与 `#!` 行**(那属于加载层,不属于词法层)。
+
+一处也没咬出来 —— 但这本身就是铺它的理由:字节流翻译最容易在读取器这一层
+丢字节或重复字节。
+
+#### 两片铺下来是干净的:协程语义、数值 `for` 与强制转换
+
+**协程(99 行)**:`yield` 到底能跨哪些边界、`close` 的语义、错误怎么传播、
+四个状态怎么转、嵌套 `resume`。实测分界(两边一致):
+
+| 能 `yield` | `pcall` / `xpcall`、数值与泛型 `for`(含自定义迭代器)、`gmatch`、
+`__index` / `__newindex` / `__add` / `__lt` / `__concat` / `__len` /
+`__call` / `__close` |
+|---|---|
+| 不能 | `table.sort` 的比较函数、`string.gsub` 的替换函数、`print` 里的
+`__tostring` —— 报 `attempt to yield across a C-call boundary` |
+
+另外几处一并盯住了:`__eq` 里 `yield` 之后协程是 **dead**(不是 suspended);
+自己 `close` 自己报 `cannot close a running coroutine`;`` 变量按
+**声明的逆序**关闭,且 `__close` 里抛错会替换掉原错误。
+
+**语法与作用域(121 行)**:用 `load()` 把六十来个片段编一遍,把**编译期**错误
+也纳入比对 —— `goto` 跳进作用域、重复标签、给 `` 赋值、`break` 在循环外、
+畸形数字与转义、函数名的各种形态。这些只有手写才碰得到,fuzzer 生成的程序
+撞不上。运行期那一半是多重赋值「右边先全部求值」、表构造器的求值顺序与键覆盖、
+变长参数(含 `nil` 混在里面)、`:` 的各种畸形用法、upvalue 共享、
+`repeat` 的条件能看见循环体里的 `local`、运算符优先级与结合性。
+
+**`debug` 与 `utf8`(114 行)**:`getinfo` 的 `what` 每个字母单独与组合、
+`getlocal`/`setlocal`/`getupvalue`/`setupvalue` 的越界、`sethook` 的四个掩码、
+`traceback` 的**形状**(不整段比对,里头有 chunk 名与地址)、`utf8` 五个函数的
+越界与非法序列。
+
+**数值 `for` 与强制转换(551 行)**:`forprep`/`forloop` 的四个分支
+(整数升/降、浮点升/降)各铺一遍,含 `maxinteger` 边界、字符串当起止、
+步长 0、浮点步长;外加 `//` / `%` / 位运算 / 字符串转换的角落。三点值得记住:
+`for i = 0, 1, 0.5` 的首值是**整数** `0`(`for i = 0, 1.0, 0.5` 则全程浮点);
+位运算**不**做字符串转换(`"10" // 2` 给 5,但 `"10" & 1` 报错);
+`for i = 2^53, 2^53+2` 与 `for i = 1e300, 1e300` 在浮点精度下是**不推进**的
+死循环 —— 探针里加了 20000 次兜底,两边在同一处跳出来。
+
+#### 一次符号审计:`warn` 整函数缺失,而 828 个用例全绿
+
+这一片与上面那些矩阵**不是一类**:那些是在「已经写到的函数」里找分歧。这次是拿
+`lbaselib.c` 的 `base_funcs[]` 逐行对 `BaseLib.open` 的注册,发现 **`warn` 压根
+没实现** —— 而 828 个用例全绿,因为没有任何一个脚本用过它。行为矩阵按构造就
+看不见「缺席的函数」。
+
+于是留下两样东西:实现,和一道**常驻闸门**——把两边的**符号集**对撞
+(190 行:全部全局、九个库的每个键、字符串元表、`package.loaded`,各打成
+`名字 类型`)。不比值:值是地址,归一化之后什么都不剩,用例会退化成
+`assertFullMatches` 本来就拦的那种空用例。
+
+同一片审计还咬出 **`_VERSION`**,第二个整缺口。它是 `Lua 5.4` 而不是
+`Lua 5.4.7`(`LUA_VERSION` 不含修订号,带修订号的叫 `LUA_RELEASE`,是
+`lua -v` 打的)。它比看上去要紧:真实项目到处是
+`if _VERSION == "Lua 5.1" then …` 这种探测,缺了 `_VERSION` 走的是 else 分支
+——**悄悄错**,而不是响亮地崩。
+
+第三个 `arg` **不是**库的缺口:`lua.c` 在 `luaL_openlibs` 之后由 `createargtable`
+造的。差分驱动既然整体在模仿 `lua.c` 的形状(`pmain`、`msghandler`、多占的那一格),
+这一张表也照着造——否则所有读 `arg[1]` 的脚本都进不了比对。
+
+实现 `warn` 要连带移植 `lauxlib.c` 的三态状态机,探针咬出三处,每一处都是
+「只测 `warn("@on")` 永远碰不到」的:
+
+| 规则 | 漏了的症状 |
+|---|---|
+| `checkcontrol` 要求 `tocont == 0` —— 所以 `warn("@on", "x")` 是把 `@onsecond` **原样打出来** | `@on` 被当控制消息吃掉 |
+| 只有 **OFF 与 ON** 两个状态查 `checkcontrol`,`warnfcont` 直接写 —— `warn("a", "@on")` 打的是 `a@on` | 一条消息里出现第二个 `Lua warning: ` 前缀 |
+| 消息是 `const char*`,且 `lua_writestringerror` 写的是**字节** | `warn("@on\0junk")` 不再能打开警告;`warn("\255")` 出来变成 `0xC3 0xBF` |
+
+最后那两条就是前面几节里的 NUL 规则与字节规则,在一个函数里同时出现——这正是
+要按**契约**铺、而不是按例子铺的理由。
+
+#### 符号闸门自己也有个洞:文件方法住在 `__index` 里
+
+闸门第一版只查 `io` 表,于是**七个文件方法整片漏看** —— `close` / `flush` /
+`lines` / `read` / `seek` / `setvbuf` / `write`。它们住在
+`getmetatable(io.stdout).__index` 里(旁边还有 `__gc` / `__close` /
+`__name = FILE*` / `__tostring`),不在 `io` 表上。符号审计必须顺着
+`__index` 往下挖才有用,现在挖了。
+
+顺着这七个方法一探,咬出一处真分歧:**`fwrite(s, 1, 0, f)` 返回 0,正好等于
+`l`,所以写 0 个字节一定成功** —— 往**只读**文件 `f:write("")` 返回的是文件
+自己,而不是 `nil, "Bad file descriptor", 9`。我们的 `write` 是先判
+`!writable` 再看长度。
+
+只有一格能把它逼出来,这正是要铺格子而不是挑例子的理由:
+
+| | 可写文件 | 只读文件 |
+| --- | --- | --- |
+| `f:write("")` | 文件(两边同) | **文件** / ~~`nil, msg, 9`~~ |
+| `f:write("a")` | 文件(两边同) | `nil, msg, 9`(两边同) |
+
+读那一侧是不对称的、本来就是对的,**而且得保持不对称**:`f:read(0)` 走的不是
+`read_chars` 而是 `test_eof`(`getc` 再 `ungetc`),所以在只写流上照样失败;
+而 `read` 是先 `done >= len` 再判 `readable`。
+
+一并实测且一致:只读流上 `flush` / `setvbuf` / `seek` 都成功;
+`io.stdout:close()` 给 `nil, "cannot close standard file"`。
+
+#### 两片「对所有库函数统一施加」的矩阵
+
+`f:write("")` 那次是**返回元数**上的 bug,而且是手摸出来的。于是补两片机械的:
+拿同一个输入施加到全部 ~130 个库函数上,比对「成功还是失败 + 每个返回值的
+**类型** + 出错时的消息」:
+
+* **零参**(约 120 行)—— 铺「参数缺失」这条路;
+* **一个错类型实参**(`{}` / `nil` / `1`,359 行)—— 铺「类型不对」那条路。
+ 两条在 `lauxlib.c` 里是两个不同的函数(`luaL_check*` 与 `luaL_opt*`
+ 对「缺失」的处理不一样),只铺一条会漏掉另一条的报错文本。
+
+零参那片没咬出新东西,错类型那片铺下来也是干净的。留着是因为它们一次性
+钉住 ~370 个调用形状:以后改任何一个库的报错文本、返回值个数,或者少一个参数
+校验,这里立刻红 —— 而这类角落手写矩阵往往不碰。
+
+四样必须排除或归一化,每一样都是踩出来的:
+
+| | 理由 |
+| --- | --- |
+| `os.exit` | 会真的杀掉进程 |
+| `io.*`、`dofile`、`loadfile` | 动默认流或读 stdin,两边不一样 |
+| `debug.debug()` | 进交互式提示符(`lua_debug>`)并读命令 |
+| `math.atan` / `math.atan2` | **同一个 C 函数**(`{"atan", math_atan}` 与
+`{"atan2", math_atan}`),报错里报哪个取决于 `findfield` 用 `lua_next` 撞见谁,
+而字符串哈希种子每次进程随机 —— 参考实现连跑六次,三次 `atan`、
+三次 `atan2`,它自己对自己都不一致。先把名字统一成 `atan` 再比。 |
+
+值一律不打印、只打 `type()`:`os.time()` / `os.date()` / `math.random()` 的值
+每次都不同。
+
+#### 钩子的格子:掩码 × count × 线程
+
+已有的钩子用例是「一个掩码一片」,这片是**全格子** —— 八个掩码、三种 count、
+四个函数体,外加按线程安装。因为好几条语义只在格子里看得出来:
+
+* `makemask` 用 `strchr`、只认 `c` / `r` / `l`:`"x"` 与 `"CC"` 都是**空掩码**
+ (而且大小写敏感)。只看 `"crl"` 永远发现不了。
+* **`lua_sethook` 在 `mask == 0` 时把钩子整个关掉** —— 所以
+ `debug.sethook(f, "")` **装不上**钩子,`debug.gethook()` 返回 `nil`,
+ 而不是「装了一个什么都不触发的钩子」。`debug.sethook(f, "x")` 同理。
+* `count` 只有 `> 0` 才并进 `LUA_MASKCOUNT`:传 0 与 -1 都没有 count 事件,
+ 而 `""` + count 1 是**只有** count 事件。
+* 钩子是**按线程**装的:主线程的行钩子在协程里一行都不触发;
+ `debug.sethook(co, …)` 只管那个协程。
+* `gethook` 的掩码字符串**不含** count(`unmakemask` 只输出 c / r / l),
+ count 是第三个返回值单独给的。
+
+两样形状上的约束与断言同等重要:事件**收集起来最后统一打印** —— 钩子开着的
+时候每一个 `print` 都会自己产生一对 call / return,直接把结果淹掉;钩子体本身
+跨好几行、还调函数 —— `allowhook` 的重入保护要是坏了,这里是无限递归而不是
+比对失败。
+
+铺下来是干净的。
+
+#### 整数 / 浮点子类型网格
+
+5.4 里「数字」分成两个**子类型**,`math.type` 看得见差别 —— 这是移植 5.4 最容易
+错的一类:写成「一律 double」的实现大多脚本能跑,只在 `//` / `%` /
+`string.format("%d")` / `math.tointeger` 这些地方悄悄不同。所以把标准库里**所有
+会返回数字**的门口过一遍,只比子类型(不比值:`os.time` 与 `math.random` 每次
+都不同)。
+
+156 行,分两格:常用形状(数学库「整型进整型出、浮点进浮点出」、`tonumber` 的
+四种写法、位运算、`string.unpack` 各宽度、`io` 的 seek、`os.date` 各字段),
+以及**边界**(`maxinteger + 1`、`mininteger // -1`、`math.fmod(1, 0)` 与
+`1 // 0` 是**抛错**而不是给 nan、`math.floor(-0.0)` 是整数、
+`math.tointeger(2^63)` 是 nil)。
+
+每条都套在 `pcall` 里 —— 那两个抛错的就是这么发现的。不套的话脚本在第一条就死,
+后面一百行一条也不会比,而用例照样是绿的。
+
+这片第一版有个值得记下的 bug:合并两个探针脚本时,第二半的调用落到了第一半那个
+「收值不收 thunk」的同名 helper 上,于是 156 行里 72 行静默变成 `tag nil`,
+而用例依然是绿的。**一整片都是 `nil` 的矩阵什么也没证明** —— 相信「铺下来干净」
+之前,先看一眼参考输出里有没有整片相同值的行。
+
+#### 子进程那一片:`luaL_pushfail` 是 `nil` 不是 `false`
+
+`io.popen`、`io.tmpfile`、`setvbuf` 是 io 库里最薄的一块 —— 各只有一两个用例。
+四个脚本(管道读写、模式校验、tmpfile、`setvbuf` 各模式)铺开之后,第一跑就咬到:
+
+```
+e3 true nil exit 3 <- 参考实现
+e3 true false exit 3 <- 我们
+```
+
+子进程非零退出时 `luaL_execresult` 压的是 `luaL_pushfail`,而 5.4 里这个宏
+就是 `lua_pushnil`(`lauxlib.h: #define luaL_pushfail(L) lua_pushnil(L)`)
+—— 从 5.4 把「fail」与 `false` 拆开起就是这样。整个项目里它一直看不出来,是因为
+`os.execute` 自己另有一份逻辑、那份本来是对的;只有 `io.popen` 的收尾走共用的
+`execResult`,于是 popen 是唯一能暴露它的门口。**同一个 helper 有两份实现,
+bug 就藏在没人测的那一份里。**
+
+这里有三样东西是实测定为「不比」的,每一条都是量过的,不是托辞:
+
+* **子进程的输出/错误必须在命令里重定向掉。** 继承来的描述符不进本实现的捕获
+ 缓冲(Java 的子进程继承的是 JVM 真那个 stderr),两边对不齐。所以写的是
+ `cat > /dev/null` 与 `2>/dev/null`,不是 `cat` 与裸命令。
+* **不排空管道就 `close` 是竞态。** 参考实现拿到 `nil, "signal", 13`
+ (`echo` 被 SIGPIPE 杀掉),我们给 `true, "exit", 0`。取决于子进程有没有先
+ 写完,逐次运行不稳定,不能进比对。
+* **`setvbuf` 负的 / 极大的 size 是 libc 自己定的。** C 把 size 当 `size_t`
+ 交给 libc 的 `setvbuf`,能不能分配出来由 libc 决定:同一台机器上 `2^31`
+ 失败而 `2^40` 成功,`"no"` 配 `-1` 成功而 `"line"` 配 `-1` 失败。
+ Java 侧没法合理建模。
+
+#### 第三片矩阵:成功路径
+
+前两片对每个库函数的矩阵问的是两件事:函数在不在(符号审计),
+以及喂错参数时怎么报错(零参 / 错类型那两片)。两条走的都是
+`luaL_check*` / `luaL_opt*` —— **检查分支**。检查通过之后那段计算本体,
+两片一行都没碰:一个算错的 `string.rep` 在它们底下会一直绿。
+
+于是补第三片:给每个库函数喂一组正常实参,比返回值本身而不是类型。
+209 行,三个脚本 —— string / math(68 行)、table / utf8 / os / coroutine
+(44 行)、io / debug / 全局(97 行)。
+
+值由 `d()` 描述,既确定性又不失真:数字带 `math.type` 子类型、字符串带长度、
+表递归展开两层并按键排序(`pairs` 顺序与地址都进不来)。只有少数几处比类型
+不比值:`os.time`、`os.clock`、`collectgarbage("count")`、表的 `tostring`。
+
+两个值得一提的结果:带种子的 `math.random` 对上了(xoshiro256** 的**序列**是对的,
+不只是取值范围);`getmetatable("")` 一行就把整个字符串元表连同 `__index` 里
+十七个方法全倒出来了。
+
+**矩阵的敏感性是量过的,不是假设的。** 把 `string.upper` 改坏一格
+(`'a'..'z'` 挪成 `'B'..'Z'`),这一片立刻红在 `s.upper [2] q(3)[Abc]`。
+不会红的矩阵比没有矩阵更糟,而唯一知道的办法就是改坏一处、看它红不红。
+
+209 行逐字节一致,第一跑就干净 —— 这本身也是可疑结果(见上面「整片都是 nil
+的矩阵什么也没证明」那条)。这里 209 行里有 205 行带着真值,所以不是空转。
+
+#### `io.open` 的模式矩阵,以及 stdio 说了算的三件事
+
+`l_checkmode` 认的是 `[rwa]%+?b*`,而且尾巴是 `strspn(mode, "b") == strlen(mode)`,
+不是「最多一个 b」—— 所以 `"rbb"` 合法而 `"rb+"` 不合法,`"r b"` / `"br"` /
+`"R"` / `"r\t"` 全失败。34 种模式 ×(读 / 定位 / 写 / 位置 / 关 / 最终内容),
+79 行。
+
+咬出**三处**分歧。三处都是 **stdio** 定的、不是 Lua 定的,而且每一处都是量过
+才放到一边的:
+
+1. **更新模式上「读完不定位直接写」** —— ISO C 里这本就是未定义行为 —— 落点由
+ C 的**预读缓冲**决定。实测:12 字节的文件写到 13(EOF),6002 字节的写到
+ 4097(BUFSIZ+1),而 `read(5000)` 又快又准地写到 5001(大块读绕过缓冲)。
+ 矩阵改成每次写之前显式 `seek` 一次绕开这条 UB 路径 —— 绕开之后
+ (`read → seek → write`)两边**逐字节一致**。
+2. **append 档上写完的位置。** `seek("set",0)` 再写 1 字节:参考给 **1**
+ (`base+n`),我们给 13(`EOF+n`,这一边才符合 POSIX)。再量下去发现它也是
+ 缓冲定的:`n<=4095` 给 `base+n`、`n>=4096` 给 `EOF+n`。所以矩阵在量位置之前
+ 先 `flush`,把位置压到 POSIX 那个唯一确定的答案上,两边才可比。
+ **写进去的内容两边始终一致**,分歧只在 `seek("cur",0)` 报的数。
+3. **标准流上的 `seek`** 取决于真实 fd 可不可定位:重定向到文件给 0、
+ `/dev/null` 给 0、管道与 tty 给 `"Illegal seek"`。本实现拿不到那个 fd,不比。
+
+其中两处与 `setvbuf` 的 size 是同一个形状:C 把决定权交给 libc,没什么可建模的。
+差别在于这里的**可观测量**不只是 errno 而是文件偏移 —— 所以要写下来,而不是
+悄悄排除。
+
+#### 让 `require` 真去磁盘上找模块
+
+`package` 那道闸门之前只走了搜索器链的「纯 Lua 那一半」(在内存里摆
+`package.preload`),而 `PackageLib` 是未覆盖率第二高的类。所以补一片
+把模块真写进磁盘再让 `require` 去找的矩阵,57 行:模块返回
+nil / false / true / 数字 / 无返回 / 多值(只取第一个)、模块抛错、模块语法错、
+模块里自己设 `package.loaded`、点号模块名换目录分隔符、第二次 `require` 的缓存、
+循环 `require`(参考给 `C stack overflow`,不是哨兵)、`package.loaded` 预置成
+值 / false / nil 三种后果、`package.preload` 给函数 / 返回 nil / 抛错 / 非函数、
+`searchpath` 的 `sep` 与 `rep`、`package.searchers` 的个数与逐个调用、整条链被换掉。
+
+顺带钉住一件会悄悄坏掉的事:每条 `module 'X' not found` 都把候选路径整串
+打印出来,所以默认的 `package.path` 必须与参考二进制编译进去的
+`LUA_PATH_DEFAULT` 逐字符一致。
+
+57 行第一跑逐字节一致 —— 所以敏感性照旧是量过的:把 `searchpath` 的默认 `sep`
+从 `"."` 改成 `"-"`,立刻红在 `sp_dots`。
+
+#### 逐行读覆盖率报告
+
+按「类的未覆盖率」挑目标只能挑到这一步 —— 模式矩阵与 require 矩阵铺完之后,
+那几个大类的缺口几乎没动,因为一类里没跑到的指令大半是
+`catch (IOException e)`,Lua 脚本根本走不到。所以再往下走一层:打开
+`target/site/jacoco/<包>/IoLib.java.html`(XML 报告没有行级信息,HTML 有),
+把 `class="nc"` 的行号抄出来,逐条问「**这一行 Lua 能走到吗**」。
+
+`IoLib` 的 63 行未覆盖里,大多数确实走不到。剩下这些是能走到却从没走到的,
+也就是真正漏测的行为,39 行:
+
+* 已关闭句柄的 `tostring` 是 `"file (closed)"` —— **不是** `io.type` 的
+ `"closed file"`。两个字符串不一样,之前只测了其中一个。
+* `io.lines()` **无参**与显式 `nil`:走注册表里的 `IO_INPUT`,与
+ `io.lines(文件名)` 不是同一条路。
+* `f:read("n")` 的各形状:指数(`1e5`,以及 `1e999` 给 inf)、十六进制
+ (`0x10` / `0x1p4` / `0x.8p1`)、前导零(`0777` 是十进制不是八进制)、
+ 残缺前缀(`0x` 与 `1e` 给 nil)、超长数字串(给 nil)。
+* `lines` 迭代途中把文件关掉:迭代器再调用报 `"file is already closed"`。
+* 大块写(5000 字节)走「绕开 wbuf 直接落盘」那条分支。
+
+39 行全逐字节一致 —— 也就是说这些分支本来就是对的,只是没人跑过。
+它们的价值在回归:谁改了 `read("n")` 的前缀判断或 wbuf 的阈值,这里立刻红。
+
+#### 目录那个 bug:一条 POSIX 规则,五处全错
+
+读 PackageLib 的未覆盖行、逐条问「Lua 能不能走到」,摸到 `searcher_C` 的兜底
+分支,再摸到「模块名撞上目录」这件事 —— 结果是**一条规则没建模,五个门口全错**:
+
+POSIX 允许 `open(dir, O_RDONLY)` 成功。于是 `fopen(dir,"r")` 成功、
+`fopen(dir,"w")` 给 `EISDIR`、第一次 `fgetc`/`fread` 失败于 `EISDIR`。
+JVM 根本不让打开目录,于是本实现以同一种方式在五处同时错:
+
+| | 参考 | 修之前 |
+| --- | --- | --- |
+| `loadfile(dir)` | `nil, "cannot read
+ */
+final class Arith {
+
+ private Arith() {
+ }
+
+ /* ORDER TM / ORDER OP —— 与 lua.h 的 LUA_OP* 一致 */
+ static final int OP_ADD = 0;
+ static final int OP_SUB = 1;
+ static final int OP_MUL = 2;
+ static final int OP_MOD = 3;
+ static final int OP_POW = 4;
+ static final int OP_DIV = 5;
+ static final int OP_IDIV = 6;
+ static final int OP_BAND = 7;
+ static final int OP_BOR = 8;
+ static final int OP_BXOR = 9;
+ static final int OP_SHL = 10;
+ static final int OP_SHR = 11;
+ static final int OP_UNM = 12;
+ static final int OP_BNOT = 13;
+
+ /** 整数的位数 */
+ private static final int NBITS = 64;
+
+ /**
+ * {@code luaO_rawarith}:不经元方法的原始算术。
+ *
先把参数全查完,再开始拼消息。C 的注释就写在函数头上:
+ * 「Check first for errors; otherwise an error may interrupt the
+ * composition of a warning, leaving it unfinished」。所以
+ * {@code warn("PARTIAL", {})} 一条警告都不吐,而不是先吐半句再炸——
+ * 实测参考实现的 stderr 上确实什么都没有。
+ * 也没有 {@code ls->h} 那张「避免长字符串被回收」的表:C 版它同时兼作
+ * 长字符串去重,但长字符串不驻留、比较一律按内容,去重与否不可观测,
+ * 去重只是省内存。这里只做短字符串驻留({@link StringPool}),语义一致。
+ */
+public final class Lexer {
+
+ /**
+ * 单字符 token 用自身编码表示,其余从这里开始({@code UCHAR_MAX + 1})。
+ */
+ public static final int FIRST_RESERVED = 256 + 1;
+
+ /* ORDER RESERVED —— 顺序必须与 C 的 enum RESERVED 完全一致 */
+ public static final int TK_AND = FIRST_RESERVED + 0;
+ public static final int TK_BREAK = FIRST_RESERVED + 1;
+ public static final int TK_DO = FIRST_RESERVED + 2;
+ public static final int TK_ELSE = FIRST_RESERVED + 3;
+ public static final int TK_ELSEIF = FIRST_RESERVED + 4;
+ public static final int TK_END = FIRST_RESERVED + 5;
+ public static final int TK_FALSE = FIRST_RESERVED + 6;
+ public static final int TK_FOR = FIRST_RESERVED + 7;
+ public static final int TK_FUNCTION = FIRST_RESERVED + 8;
+ public static final int TK_GOTO = FIRST_RESERVED + 9;
+ public static final int TK_IF = FIRST_RESERVED + 10;
+ public static final int TK_IN = FIRST_RESERVED + 11;
+ public static final int TK_LOCAL = FIRST_RESERVED + 12;
+ public static final int TK_NIL = FIRST_RESERVED + 13;
+ public static final int TK_NOT = FIRST_RESERVED + 14;
+ public static final int TK_OR = FIRST_RESERVED + 15;
+ public static final int TK_REPEAT = FIRST_RESERVED + 16;
+ public static final int TK_RETURN = FIRST_RESERVED + 17;
+ public static final int TK_THEN = FIRST_RESERVED + 18;
+ public static final int TK_TRUE = FIRST_RESERVED + 19;
+ public static final int TK_UNTIL = FIRST_RESERVED + 20;
+ public static final int TK_WHILE = FIRST_RESERVED + 21;
+ public static final int TK_IDIV = FIRST_RESERVED + 22;
+ public static final int TK_CONCAT = FIRST_RESERVED + 23;
+ public static final int TK_DOTS = FIRST_RESERVED + 24;
+ public static final int TK_EQ = FIRST_RESERVED + 25;
+ public static final int TK_GE = FIRST_RESERVED + 26;
+ public static final int TK_LE = FIRST_RESERVED + 27;
+ public static final int TK_NE = FIRST_RESERVED + 28;
+ public static final int TK_SHL = FIRST_RESERVED + 29;
+ public static final int TK_SHR = FIRST_RESERVED + 30;
+ public static final int TK_DBCOLON = FIRST_RESERVED + 31;
+ public static final int TK_EOS = FIRST_RESERVED + 32;
+ public static final int TK_FLT = FIRST_RESERVED + 33;
+ public static final int TK_INT = FIRST_RESERVED + 34;
+ public static final int TK_NAME = FIRST_RESERVED + 35;
+ public static final int TK_STRING = FIRST_RESERVED + 36;
+
+ /** 保留字个数({@code NUM_RESERVED}) */
+ public static final int NUM_RESERVED = TK_WHILE - FIRST_RESERVED + 1;
+
+ /** {@code luaX_tokens} */
+ private static final String[] TOKENS = {
+ "and", "break", "do", "else", "elseif",
+ "end", "false", "for", "function", "goto", "if",
+ "in", "local", "nil", "not", "or", "repeat",
+ "return", "then", "true", "until", "while",
+ "//", "..", "...", "==", ">=", "<=", "~=",
+ "<<", ">>", "::", "",
+ "", "", "", ""
+ };
+
+ private static final Map RESERVED = new HashMap<>();
+
+ static {
+ for (int i = 0; i < NUM_RESERVED; i++) {
+ RESERVED.put(TOKENS[i], FIRST_RESERVED + i);
+ }
+ }
+
+ /** {@code SemInfo + Token}:语义信息随 token 类型不同落在不同的字段上 */
+ public static final class Token {
+ public int token = TK_EOS;
+ /** TK_NAME / TK_STRING */
+ public LuaString s;
+ /** TK_INT */
+ public long i;
+ /** TK_FLT */
+ public double r;
+
+ void reset() {
+ token = TK_EOS;
+ s = null;
+ i = 0;
+ r = 0;
+ }
+
+ /** 快照:{@code next()} 之后 {@code t} 会被原地改写,需要留存的场合必须先复制 */
+ public Token copy() {
+ Token c = new Token();
+ c.token = token;
+ c.s = s;
+ c.i = i;
+ c.r = r;
+ return c;
+ }
+
+ }
+
+ /**
+ * {@code luaX_token2str}。
+ *
+ * 除了驻留短串,这里还多一步:长串也要去重。C 版把 {@code ls->h}
+ * 当集合用(键是串本身),命中就返回表里存的那一副本;注释写得很明白
+ * ——{@code "It also internalizes long strings, ensuring there is only one
+ * copy of each unique string"}。
+ *