summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCliff Click <cliffc@acm.org>2026-08-10 11:11:18 -0700
committerCliff Click <cliffc@acm.org>2026-11-12 23:28:58 -0800
commite6d24a68ba902f96c317f0329de4d61032177e6b (patch)
tree89d9433092cf7bd0759a1b82251f5d26e43cb5e1
parent2cb35c65bde63f90d7afc3d7a8a985b0c6ea15d7 (diff)
downloadSimple-e6d24a68ba902f96c317f0329de4d61032177e6b.tar.gz
Simple-e6d24a68ba902f96c317f0329de4d61032177e6b.zip
Chapter 31: Software Drawing, Time, and Graphical Gameslinear
Co-authored-by: Hsiang-Ying Fu <66062782+fuhsnn@users.noreply.github.com> Co-authored-by: fuhsnn <66062782+fuhsnn@users.noreply.github.com> Co-authored-by: Symious <14933944+symious@users.noreply.github.com> Co-authored-by: Jun Kawasaki <jun784@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--.argsT.txt1
-rw-r--r--.out.txt304
-rw-r--r--Makefile26
-rw-r--r--README.ja.md2
-rw-r--r--README.md2
-rw-r--r--audio.mk31
-rw-r--r--chapter31.iml (renamed from chapter30.iml)4
-rw-r--r--docs/chapter31/31-grammar.ja.md182
-rw-r--r--docs/chapter31/31-grammar.md266
-rw-r--r--docs/chapter31/README.ja.md20
-rw-r--r--docs/chapter31/README.md62
-rw-r--r--docs/chapter31/audio.md62
-rw-r--r--docs/chapter31/construction-groups.md241
-rw-r--r--docs/chapter31/deferred-construction.md385
-rw-r--r--docs/chapter31/drawing.md100
-rw-r--r--docs/chapter31/escape-flow.md147
-rw-r--r--docs/chapter31/examples/A_helloWorld.smp2
-rw-r--r--docs/chapter31/examples/BubbleSort.smp38
-rw-r--r--docs/chapter31/examples/Capitalize.smp23
-rw-r--r--docs/chapter31/examples/Clock.smp105
-rw-r--r--docs/chapter31/examples/Dijkstra.smp74
-rw-r--r--docs/chapter31/examples/FileIO.smp52
-rw-r--r--docs/chapter31/examples/Graphics.smp17
-rw-r--r--docs/chapter31/examples/README.md84
-rw-r--r--docs/chapter31/examples/TicTacToe.smp133
-rw-r--r--docs/chapter31/examples/TicTacToeGraphical.smp167
-rw-r--r--docs/chapter31/graphics.md132
-rw-r--r--docs/chapter31/time.md119
-rw-r--r--graphics.mk70
-rw-r--r--src/main/c/simple_audio.c40
-rw-r--r--src/main/c/simple_audio.h16
-rw-r--r--src/main/c/simple_graphics.c102
-rw-r--r--src/main/c/simple_graphics.h20
-rw-r--r--src/main/c/simple_miniaudio.c123
-rw-r--r--src/main/c/simple_minifb.c117
-rw-r--r--src/main/c/simple_time.c61
-rw-r--r--src/main/java/com/seaofnodes/simple/README.md2
-rw-r--r--src/main/java/com/seaofnodes/simple/codegen/Encoding.java10
-rw-r--r--src/main/java/com/seaofnodes/simple/codegen/Serialize.java12
-rw-r--r--src/main/java/com/seaofnodes/simple/node/LoadNode.java16
-rw-r--r--src/main/java/com/seaofnodes/simple/node/MemMergeNode.java16
-rw-r--r--src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/JmpX86.java27
-rw-r--r--src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/NegX86.java2
-rw-r--r--src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/NotX86.java2
-rw-r--r--src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/SetX86.java18
-rw-r--r--src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/x86_64_v2.java17
-rw-r--r--src/main/java/com/seaofnodes/simple/parser/Expressions.java2
-rw-r--r--src/main/java/com/seaofnodes/simple/parser/Lexer.java35
-rw-r--r--src/main/java/com/seaofnodes/simple/parser/README.md2
-rw-r--r--src/main/java/com/seaofnodes/simple/parser/Strings.java2
-rw-r--r--src/main/java/com/seaofnodes/simple/parser/package-info.java2
-rw-r--r--src/main/java/com/seaofnodes/simple/print/ASMPrinter.java4
-rw-r--r--src/main/java/com/seaofnodes/simple/type/TypeMem.java9
-rw-r--r--src/main/smp/sys.smp4
-rw-r--r--src/main/smp/sys/audio.smp29
-rw-r--r--src/main/smp/sys/draw.smp18
-rw-r--r--src/main/smp/sys/draw/Button.smp41
-rw-r--r--src/main/smp/sys/draw/Canvas.smp176
-rw-r--r--src/main/smp/sys/gfx.smp42
-rw-r--r--src/main/smp/sys/text.smp5
-rw-r--r--src/main/smp/sys/time.smp191
-rw-r--r--src/main/smp/sys/time/DateTime.smp11
-rw-r--r--src/main/smp/sys/time/Timers.smp55
-rw-r--r--src/test/c/audio_test.c48
-rw-r--r--src/test/java/com/seaofnodes/simple/AudioTest.java94
-rw-r--r--src/test/java/com/seaofnodes/simple/BrainFuckTest.java8
-rw-r--r--src/test/java/com/seaofnodes/simple/Chapter01Test.java9
-rw-r--r--src/test/java/com/seaofnodes/simple/Chapter21Test.java4
-rw-r--r--src/test/java/com/seaofnodes/simple/Chapter25Test.java4
-rw-r--r--src/test/java/com/seaofnodes/simple/DrawTest.java221
-rw-r--r--src/test/java/com/seaofnodes/simple/FloatComparisonNativeTest.java65
-rw-r--r--src/test/java/com/seaofnodes/simple/GraphicsTest.java127
-rw-r--r--src/test/java/com/seaofnodes/simple/IntegerLiteralTest.java46
-rw-r--r--src/test/java/com/seaofnodes/simple/ParserDiagnosticTest.java13
-rw-r--r--src/test/java/com/seaofnodes/simple/SeparateCompilationTest.java16
-rw-r--r--src/test/java/com/seaofnodes/simple/StreamIOTest.java4
-rw-r--r--src/test/java/com/seaofnodes/simple/StringFormatTest.java4
-rw-r--r--src/test/java/com/seaofnodes/simple/TestC.java5
-rw-r--r--src/test/java/com/seaofnodes/simple/TimeTest.java176
-rw-r--r--src/test/java/com/seaofnodes/simple/codegen/PrintRegTestSupport.java2
80 files changed, 4554 insertions, 372 deletions
diff --git a/.argsT.txt b/.argsT.txt
deleted file mode 100644
index 827ddba7..00000000
--- a/.argsT.txt
+++ /dev/null
@@ -1 +0,0 @@
- src/test/java/com/seaofnodes/simple/Chapter09Test.java src/test/java/com/seaofnodes/simple/ParserTest.java src/test/java/com/seaofnodes/simple/Chapter16Test.java src/test/java/com/seaofnodes/simple/TicTacToeTest.java src/test/java/com/seaofnodes/simple/ConstructionGroupTest.java src/test/java/com/seaofnodes/simple/Chapter23Test.java src/test/java/com/seaofnodes/simple/NativeExecutionTest.java src/test/java/com/seaofnodes/simple/Chapter14Test.java src/test/java/com/seaofnodes/simple/ArithNodeTest.java src/test/java/com/seaofnodes/simple/CompoundAssignmentTest.java src/test/java/com/seaofnodes/simple/Chapter14FloatTest.java src/test/java/com/seaofnodes/simple/Chapter10Test.java src/test/java/com/seaofnodes/simple/BulkMemPhiTest.java src/test/java/com/seaofnodes/simple/MemoryEncodingTest.java src/test/java/com/seaofnodes/simple/ConstructionRequirementsTest.java src/test/java/com/seaofnodes/simple/Chapter13Test.java src/test/java/com/seaofnodes/simple/Chapter06Test.java src/test/java/com/seaofnodes/simple/TestRunner.java src/test/java/com/seaofnodes/simple/Chapter03Test.java src/test/java/com/seaofnodes/simple/FuzzerWrap.java src/test/java/com/seaofnodes/simple/EscapeFlowTest.java src/test/java/com/seaofnodes/simple/Int32Test.java src/test/java/com/seaofnodes/simple/Chapter24Test.java src/test/java/com/seaofnodes/simple/TerminalInputTest.java src/test/java/com/seaofnodes/simple/ConstructorCompletionTest.java src/test/java/com/seaofnodes/simple/RuntimeArgsTest.java src/test/java/com/seaofnodes/simple/Chapter04Test.java src/test/java/com/seaofnodes/simple/ChainedComparisonTest.java src/test/java/com/seaofnodes/simple/SeparateCompilationTest.java src/test/java/com/seaofnodes/simple/MutabilityTest.java src/test/java/com/seaofnodes/simple/FinalConstructionTest.java src/test/java/com/seaofnodes/simple/Chapter28bTest.java src/test/java/com/seaofnodes/simple/Chapter21Test.java src/test/java/com/seaofnodes/simple/Chapter07Test.java src/test/java/com/seaofnodes/simple/BrainFuckTest.java src/test/java/com/seaofnodes/simple/Chapter05Test.java src/test/java/com/seaofnodes/simple/PhaseTest.java src/test/java/com/seaofnodes/simple/Eval2.java src/test/java/com/seaofnodes/simple/TestArm64.java src/test/java/com/seaofnodes/simple/Chapter12Test.java src/test/java/com/seaofnodes/simple/StringFormatTest.java src/test/java/com/seaofnodes/simple/TestC.java src/test/java/com/seaofnodes/simple/IncrementalBuildTest.java src/test/java/com/seaofnodes/simple/Simple.java src/test/java/com/seaofnodes/simple/SelectiveLoadingTest.java src/test/java/com/seaofnodes/simple/Chapter02Test.java src/test/java/com/seaofnodes/simple/Chapter15Test.java src/test/java/com/seaofnodes/simple/BoundaryTest.java src/test/java/com/seaofnodes/simple/Chapter25Test.java src/test/java/com/seaofnodes/simple/Chapter22Test.java src/test/java/com/seaofnodes/simple/PartialReadTest.java src/test/java/com/seaofnodes/simple/MergeSortTest.java src/test/java/com/seaofnodes/simple/SpillStats.java src/test/java/com/seaofnodes/simple/Chapter17bTest.java src/test/java/com/seaofnodes/simple/CallEndTest.java src/test/java/com/seaofnodes/simple/Chapter01Test.java src/test/java/com/seaofnodes/simple/MinusNodeTest.java src/test/java/com/seaofnodes/simple/Chapter18Test.java src/test/java/com/seaofnodes/simple/TypeTest.java src/test/java/com/seaofnodes/simple/StaticDataTest.java src/test/java/com/seaofnodes/simple/TestRisc5.java src/test/java/com/seaofnodes/simple/ClassInitDependenciesTest.java src/test/java/com/seaofnodes/simple/ConvertNodeTest.java src/test/java/com/seaofnodes/simple/Chapter08Test.java src/test/java/com/seaofnodes/simple/Chapter17aTest.java src/test/java/com/seaofnodes/simple/Chapter19Test.java src/test/java/com/seaofnodes/simple/ParserDiagnosticTest.java src/test/java/com/seaofnodes/simple/PrefixUpdateTest.java src/test/java/com/seaofnodes/simple/Chapter20Test.java src/test/java/com/seaofnodes/simple/Main.java src/test/java/com/seaofnodes/simple/StreamIOTest.java src/test/java/com/seaofnodes/simple/codegen/ParseAllTestSupport.java src/test/java/com/seaofnodes/simple/codegen/PrintRegTestSupport.java src/test/java/com/seaofnodes/simple/codegen/FlagSpillTestSupport.java src/test/java/com/seaofnodes/simple/codegen/RegAllocTestSupport.java src/test/java/com/seaofnodes/simple/fuzzer/Reducer.java src/test/java/com/seaofnodes/simple/fuzzer/ScriptGenerator.java src/test/java/com/seaofnodes/simple/fuzzer/FuzzerUtils.java src/test/java/com/seaofnodes/simple/fuzzer/Fuzzer.java ../isa/src/test-support/java/com/seaofnodes/isa/eval/EvalArm64.java ../isa/src/test-support/java/com/seaofnodes/isa/eval/EvalRisc5.java
diff --git a/.out.txt b/.out.txt
deleted file mode 100644
index be785578..00000000
--- a/.out.txt
+++ /dev/null
@@ -1,304 +0,0 @@
-src\test\java\com\seaofnodes\simple\Chapter09Test.java:6: error: package org.junit does not exist
-import org.junit.Assert;
- ^
-src\test\java\com\seaofnodes\simple\Chapter09Test.java:7: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\Chapter09Test.java:8: error: package org.junit does not exist
-import org.junit.Ignore;
- ^
-src\test\java\com\seaofnodes\simple\Chapter09Test.java:10: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\ParserTest.java:5: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\ParserTest.java:6: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\Chapter16Test.java:4: error: package org.junit does not exist
-import org.junit.Ignore;
- ^
-src\test\java\com\seaofnodes\simple\Chapter16Test.java:5: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\Chapter16Test.java:6: error: package org.junit does not exist
-import static org.junit.Assert.assertEquals;
- ^
-src\test\java\com\seaofnodes\simple\Chapter16Test.java:6: error: static import only from classes and interfaces
-import static org.junit.Assert.assertEquals;
-^
-src\test\java\com\seaofnodes\simple\Chapter16Test.java:7: error: package org.junit does not exist
-import static org.junit.Assert.fail;
- ^
-src\test\java\com\seaofnodes\simple\Chapter16Test.java:7: error: static import only from classes and interfaces
-import static org.junit.Assert.fail;
-^
-src\test\java\com\seaofnodes\simple\TicTacToeTest.java:8: error: package org.junit does not exist
-import org.junit.BeforeClass;
- ^
-src\test\java\com\seaofnodes\simple\TicTacToeTest.java:9: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\TicTacToeTest.java:10: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\ConstructionGroupTest.java:5: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\ConstructionGroupTest.java:6: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\codegen\RegAllocTestSupport.java:7: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\Chapter23Test.java:17: error: package org.junit does not exist
-import org.junit.Ignore;
- ^
-src\test\java\com\seaofnodes\simple\Chapter23Test.java:18: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\Chapter23Test.java:19: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\NativeExecutionTest.java:6: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\NativeExecutionTest.java:7: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\NativeExecutionTest.java:8: error: package org.junit does not exist
-import static org.junit.Assume.assumeTrue;
- ^
-src\test\java\com\seaofnodes\simple\NativeExecutionTest.java:8: error: static import only from classes and interfaces
-import static org.junit.Assume.assumeTrue;
-^
-src\test\java\com\seaofnodes\simple\Chapter14Test.java:5: error: package org.junit does not exist
-import org.junit.Ignore;
- ^
-src\test\java\com\seaofnodes\simple\Chapter14Test.java:6: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\Chapter14Test.java:7: error: package org.junit does not exist
-import static org.junit.Assert.assertEquals;
- ^
-src\test\java\com\seaofnodes\simple\Chapter14Test.java:7: error: static import only from classes and interfaces
-import static org.junit.Assert.assertEquals;
-^
-src\test\java\com\seaofnodes\simple\Chapter14Test.java:8: error: package org.junit does not exist
-import static org.junit.Assert.fail;
- ^
-src\test\java\com\seaofnodes\simple\Chapter14Test.java:8: error: static import only from classes and interfaces
-import static org.junit.Assert.fail;
-^
-src\test\java\com\seaofnodes\simple\ArithNodeTest.java:8: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\ArithNodeTest.java:9: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\CompoundAssignmentTest.java:5: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\CompoundAssignmentTest.java:6: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\Chapter14FloatTest.java:4: error: package org.junit does not exist
-import org.junit.Ignore;
- ^
-src\test\java\com\seaofnodes\simple\Chapter14FloatTest.java:5: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\Chapter14FloatTest.java:6: error: package org.junit does not exist
-import static org.junit.Assert.assertEquals;
- ^
-src\test\java\com\seaofnodes\simple\Chapter14FloatTest.java:6: error: static import only from classes and interfaces
-import static org.junit.Assert.assertEquals;
-^
-src\test\java\com\seaofnodes\simple\Chapter10Test.java:5: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\Chapter10Test.java:6: error: package org.junit does not exist
-import static org.junit.Assert.assertEquals;
- ^
-src\test\java\com\seaofnodes\simple\Chapter10Test.java:6: error: static import only from classes and interfaces
-import static org.junit.Assert.assertEquals;
-^
-src\test\java\com\seaofnodes\simple\Chapter10Test.java:7: error: package org.junit does not exist
-import static org.junit.Assert.fail;
- ^
-src\test\java\com\seaofnodes\simple\Chapter10Test.java:7: error: static import only from classes and interfaces
-import static org.junit.Assert.fail;
-^
-src\test\java\com\seaofnodes\simple\BulkMemPhiTest.java:7: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\BulkMemPhiTest.java:8: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\MemoryEncodingTest.java:6: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\MemoryEncodingTest.java:7: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\ConstructionRequirementsTest.java:5: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\ConstructionRequirementsTest.java:6: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\ConstructionRequirementsTest.java:8: error: package org.junit does not exist
-@org.junit.Ignore("Deferred: inferred field requirements and incomplete-object traversal")
- ^
-src\test\java\com\seaofnodes\simple\Chapter13Test.java:5: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\Chapter13Test.java:6: error: package org.junit does not exist
-import static org.junit.Assert.assertEquals;
- ^
-src\test\java\com\seaofnodes\simple\Chapter13Test.java:6: error: static import only from classes and interfaces
-import static org.junit.Assert.assertEquals;
-^
-src\test\java\com\seaofnodes\simple\Chapter06Test.java:6: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\Chapter06Test.java:8: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\TestRunner.java:3: error: package org.junit.internal does not exist
-import org.junit.internal.TextListener;
- ^
-src\test\java\com\seaofnodes\simple\TestRunner.java:4: error: package org.junit.runner does not exist
-import org.junit.runner.JUnitCore;
- ^
-src\test\java\com\seaofnodes\simple\TestRunner.java:5: error: package org.junit.runner does not exist
-import org.junit.runner.Request;
- ^
-src\test\java\com\seaofnodes\simple\Chapter03Test.java:4: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\Chapter03Test.java:5: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\FuzzerWrap.java:5: error: package org.junit does not exist
-import org.junit.Ignore;
- ^
-src\test\java\com\seaofnodes\simple\FuzzerWrap.java:6: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\FuzzerWrap.java:7: error: package org.junit does not exist
-import static org.junit.Assert.assertTrue;
- ^
-src\test\java\com\seaofnodes\simple\FuzzerWrap.java:7: error: static import only from classes and interfaces
-import static org.junit.Assert.assertTrue;
-^
-src\test\java\com\seaofnodes\simple\EscapeFlowTest.java:6: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\EscapeFlowTest.java:7: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\Int32Test.java:10: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\Int32Test.java:11: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\Chapter24Test.java:15: error: package org.junit does not exist
-import org.junit.Ignore;
- ^
-src\test\java\com\seaofnodes\simple\Chapter24Test.java:16: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\Chapter24Test.java:17: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\TerminalInputTest.java:12: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\TerminalInputTest.java:13: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\ConstructorCompletionTest.java:5: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\ConstructorCompletionTest.java:6: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\RuntimeArgsTest.java:4: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\RuntimeArgsTest.java:5: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\TestC.java:21: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\Chapter04Test.java:6: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\Chapter04Test.java:7: error: package org.junit does not exist
-import org.junit.Ignore;
- ^
-src\test\java\com\seaofnodes\simple\Chapter04Test.java:9: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\ChainedComparisonTest.java:5: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\ChainedComparisonTest.java:6: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\SeparateCompilationTest.java:9: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\SeparateCompilationTest.java:10: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\MutabilityTest.java:3: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\MutabilityTest.java:4: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\FinalConstructionTest.java:5: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\FinalConstructionTest.java:6: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\Chapter28bTest.java:4: error: package org.junit does not exist
-import org.junit.Ignore;
- ^
-src\test\java\com\seaofnodes\simple\Chapter28bTest.java:5: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\Chapter28bTest.java:7: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\Chapter21Test.java:13: error: package org.junit does not exist
-import org.junit.Ignore;
- ^
-src\test\java\com\seaofnodes\simple\Chapter21Test.java:14: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\Chapter21Test.java:15: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\Chapter07Test.java:5: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\Chapter07Test.java:7: error: package org.junit does not exist
-import static org.junit.Assert.*;
- ^
-src\test\java\com\seaofnodes\simple\BrainFuckTest.java:9: error: package org.junit does not exist
-import org.junit.Test;
- ^
-src\test\java\com\seaofnodes\simple\BrainFuckTest.java:10: error: package org.junit does not exist
-import static org.junit.Assert.assertEquals;
- ^
-Note: Some input files use unchecked or unsafe operations.
-Note: Recompile with -Xlint:unchecked for details.
-100 errors
-only showing the first 100 errors, of 2590 total; use -Xmaxerrs if you would like to see more
diff --git a/Makefile b/Makefile
index 4503c993..c9a398e8 100644
--- a/Makefile
+++ b/Makefile
@@ -37,6 +37,9 @@ RELEASE:= build/release
TEST_SYS:= $(OBJS)/lib_$(CPU_ABI)/sys.o
RELEASE_SYS:= $(RELEASE)/$(CPU_ABI)/sys.o
RELEASE_CRT:= $(RELEASE)/$(CPU_ABI)/simple_crt.o
+RELEASE_GRAPHICS:= $(RELEASE)/$(CPU_ABI)/simple_graphics.o
+RELEASE_AUDIO:= $(RELEASE)/$(CPU_ABI)/simple_audio.o
+RELEASE_TIME:= $(RELEASE)/$(CPU_ABI)/simple_time.o
RELEASE_ARGS:= $(RELEASE)/$(CPU_ABI)/simple_args.o
C_CALL_CONV:= $(if $(filter win64,$(CALL_CONV)),ms_abi,sysv_abi)
GAME_DIR:= build/examples/$(CPU_ABI)
@@ -65,11 +68,32 @@ $(RELEASE_ARGS): src/main/smp/sys/rt/simple_args.smp $(CLZDIR)/main/.mtag $(simp
@echo "simping " $@
@$(JVM) com.seaofnodes.simple.Simple --cpu $(CPU_PORT) --abi $(CALL_CONV) --norun -o $@ $<
-$(RELEASE_CRT): src/main/c/simple_crt.c $(RELEASE_ARGS) Makefile
+$(RELEASE_CRT): src/main/c/simple_crt.c $(RELEASE_ARGS) $(RELEASE_GRAPHICS) $(RELEASE_AUDIO) $(RELEASE_TIME) Makefile
@echo "compiling " $@
@[ -d $(dir $@) ] || mkdir -p $(dir $@)
@gcc -c -O2 -DCALL_CONV=$(C_CALL_CONV) -o $@ $<
+$(RELEASE_GRAPHICS): src/main/c/simple_graphics.c src/main/c/simple_graphics.h Makefile
+ @echo "compiling " $@
+ @mkdir -p $(dir $@)
+ @gcc -c -O2 -Wall -Wextra -o $@ $<
+
+# Optional native graphics backend. Nothing in the default build depends on it.
+include graphics.mk
+include audio.mk
+
+$(RELEASE_AUDIO): src/main/c/simple_audio.c src/main/c/simple_audio.h Makefile
+ @mkdir -p $(dir $@)
+ @gcc -c -O2 -Wall -Wextra -o $@ $<
+
+$(RELEASE_TIME): src/main/c/simple_time.c Makefile
+ @mkdir -p $(dir $@)
+ @gcc -c -O2 -Wall -Wextra -o $@ $<
+
+.PHONY: tests_nographics
+tests_nographics:
+ @SIMPLE_MINIFB=off SIMPLE_MINIAUDIO=off $(MAKE) tests
+
#######################################################
# Base launch line for JVM tests
diff --git a/README.ja.md b/README.ja.md
index 77f2d0b2..c331e47f 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -70,7 +70,7 @@ Simple 言語のスタイルは、C または Java のサブセットに倣っã�
* [第28b章](docs/chapter28b/README.ja.md):分離コンパイル
* [第29章](docs/chapter29/README.ja.md):技術的負債の整理
* [第30章](docs/chapter30/README.ja.md):テキスト整形と診断
-* 第31章:ソフトウェア描画とグラフィカルゲーム(作業中)
+* [第31章](docs/chapter31/README.ja.md):ソフトウェア描画とグラフィカルゲーム(作業中)
付録:[ネイティブライブラリ](docs/appendices/native-libraries.md)、
[最適化とバックエンドの調整](docs/appendices/optimizer-tuning.ja.md)。
diff --git a/README.md b/README.md
index 93a8cbd8..c30d8dc3 100644
--- a/README.md
+++ b/README.md
@@ -84,7 +84,7 @@ The Simple language is styled after a subset of C or Java.
* [Chapter 28b](docs/chapter28b/README.md): Separate Compilation
* [Chapter 29](docs/chapter29/README.md): Tech Debt Cleanup, Partial Objects
* [Chapter 30](docs/chapter30/README.md): Text Formatting and Diagnostics
-* Chapter 31: Software Drawing and Graphical Games (work in progress)
+* [Chapter 31](docs/chapter31/README.md): Software Drawing and Graphical Games (work in progress)
Appendices: [Native libraries](docs/appendices/native-libraries.md),
[optimizer and backend tuning](docs/appendices/optimizer-tuning.md).
diff --git a/audio.mk b/audio.mk
new file mode 100644
index 00000000..4aeeec20
--- /dev/null
+++ b/audio.mk
@@ -0,0 +1,31 @@
+# Optional miniaudio, independent of the default build and MiniFB.
+MINIAUDIO_REV := 0.11.23
+MINIAUDIO_SRC := lib/miniaudio-$(MINIAUDIO_REV)
+MINIAUDIO_HEADER := $(MINIAUDIO_SRC)/miniaudio.h
+ifeq ($(OS),Windows_NT)
+ AUDIO_CC := x86_64-w64-mingw32-gcc
+ AUDIO_LIB := $(RELEASE)/$(CPU_ABI)/simple_miniaudio.dll
+ AUDIO_FLAGS := -D_WIN32_WINNT=0x0601
+ AUDIO_LIBS := -lm
+else
+ AUDIO_CC := gcc
+ AUDIO_LIB := $(RELEASE)/$(CPU_ABI)/libsimple_miniaudio.so
+ AUDIO_FLAGS := -fPIC
+ AUDIO_LIBS := -lpthread -lm -ldl
+endif
+.PHONY: audio
+audio: $(AUDIO_LIB)
+.PHONY: test-audio
+test-audio: $(MINIAUDIO_HEADER)
+ @mkdir -p build/objs
+ @$(AUDIO_CC) -O2 -Wall -Wextra $(AUDIO_FLAGS) -I$(MINIAUDIO_SRC) src/test/c/audio_test.c $(AUDIO_LIBS) -o build/objs/audio_test$(if $(filter Windows_NT,$(OS)),.exe,)
+ @build/objs/audio_test$(if $(filter Windows_NT,$(OS)),.exe,)
+lib libs init: $(MINIAUDIO_HEADER)
+
+$(MINIAUDIO_HEADER):
+ @$(call download-library,https://raw.githubusercontent.com/mackron/miniaudio/$(MINIAUDIO_REV)/miniaudio.h)
+$(AUDIO_LIB): $(MINIAUDIO_HEADER) src/main/c/simple_miniaudio.c src/main/c/simple_audio.h audio.mk
+ @echo "building " $@
+ @mkdir -p $(dir $@)
+ @$(AUDIO_CC) -shared -O2 -Wall -Wextra $(AUDIO_FLAGS) -I$(MINIAUDIO_SRC) src/main/c/simple_miniaudio.c $(AUDIO_LIBS) -o $@
+ @sed -n '/^This software is available as a choice of the following licenses/,$$p' $(MINIAUDIO_HEADER) > $(dir $@)miniaudio-LICENSE
diff --git a/chapter30.iml b/chapter31.iml
index 793144ee..f4007a98 100644
--- a/chapter30.iml
+++ b/chapter31.iml
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="false">
- <output url="file://$MODULE_DIR$/../build/idea/production/chapter30" />
- <output-test url="file://$MODULE_DIR$/../build/idea/test/chapter30" />
+ <output url="file://$MODULE_DIR$/../build/idea/production/chapter31" />
+ <output-test url="file://$MODULE_DIR$/../build/idea/test/chapter31" />
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src/main/java" isTestSource="false" />
diff --git a/docs/chapter31/31-grammar.ja.md b/docs/chapter31/31-grammar.ja.md
new file mode 100644
index 00000000..f4635f21
--- /dev/null
+++ b/docs/chapter31/31-grammar.ja.md
@@ -0,0 +1,182 @@
+# 第31章の文法
+
+[English](31-grammar.md) | 日本語
+
+```antlrv4
+grammar SimpleLanguage;
+
+// Name lookup, assignable operands, type compatibility, and access permissions
+// are checked by the compiler in addition to these syntax rules.
+// A file is a compilation unit; its path supplies its namespace.
+// Qualified names resolve nested types and other units without an import keyword.
+program : statement* EOF ;
+
+block : '{' statement* '}' ;
+
+statement
+ : block
+ | returnStatement
+ | ifStatement
+ | whileStatement
+ | forStatement
+ | breakStatement
+ | continueStatement
+ | structDeclaration
+ | declaration ';'
+ | assignment ';'
+ | ';'
+ ;
+
+returnStatement : 'return' assignment ';' ;
+ifStatement : 'if' '(' assignment ')' statement ('else' statement)? ;
+whileStatement : 'while' '(' assignment ')' statement ;
+breakStatement : 'break' ';' ;
+continueStatement : 'continue' ';' ;
+
+forStatement
+ : 'for' '(' (declaration | assignment)? ';' assignment? ';' assignment? ')' statement
+ ;
+
+structDeclaration : 'struct' IDENTIFIER '{' structMember* '}' ';' ;
+structMember : constructorDeclaration | methodDeclaration | statement ;
+
+// The constructor name must match the enclosing struct's name.
+constructorDeclaration : 'new' IDENTIFIER '=' constructorExpression ';' ;
+constructorExpression : '{' constructorParameterList? '->' statement* '}' ;
+constructorParameterList : (constructorParameter ','?)+ ;
+constructorParameter : type '^'? bindingModifier? IDENTIFIER ;
+// ^ accepts an unfinished struct reference; ? independently permits null.
+// Only self may be read while incomplete; ^ arguments may be stored/forwarded.
+// A new result is incomplete iff an actual argument is. See construction-groups.md.
+
+// A method is an ordinary fixed function binding in a struct definition.
+// The compiler supplies self; it is not written in parameterList.
+// Function types and expressions both receive their implicit receiver here.
+methodDeclaration : 'val' bindingModifier? IDENTIFIER '=' functionExpression ';' ;
+
+declaration : declarationType binding (',' binding)* ;
+declarationType : type | 'var' | 'val' ;
+binding : bindingModifier? IDENTIFIER ('=' initializer)? ;
+bindingModifier : '!' | '~' ;
+// A declaration initializer consisting of the string "C" declares an extern
+// symbol: a function, or explicit numeric native storage. Ordinary strings
+// elsewhere are array-valued expressions.
+initializer : assignment ;
+
+// The prefix qualifies the named struct reference, not an enclosing array.
+// Each [] / [~] suffix qualifies its own array layer. ? requires a reference.
+type
+ : functionType
+ | primitiveType typeSuffix*
+ | accessModifier? typeName typeSuffix*
+ ;
+accessModifier : '!' | '~' ;
+typeSuffix : '?' | '[]' | '[~]' ;
+typeName : IDENTIFIER ('.' IDENTIFIER)* ;
+
+primitiveType
+ : 'int' | 'i8' | 'i16' | 'i32' | 'i64'
+ | 'u1' | 'u8' | 'u16' | 'u32' | 'byte' | 'bool'
+ | 'flt' | 'f32' | 'f64'
+ ;
+
+assignment : expression (assignmentOperator assignment)? ;
+assignmentOperator
+ : '=' | '+=' | '-=' | '*=' | '/=' | '&=' | '|=' | '^='
+ | '<<=' | '>>=' | '>>>='
+ ;
+
+expression : logicalExpression ('?' assignment (':' assignment)?)? ;
+
+// Both logical operators short-circuit and associate to the right here.
+logicalExpression : bitwiseExpression (('&&' | '||') logicalExpression)? ;
+
+bitwiseExpression
+ : equalityExpression (('&' | '|' | '^') equalityExpression)*
+ ;
+
+equalityExpression : comparisonExpression (('==' | '!=') comparisonExpression)* ;
+
+// Chained comparisons test adjacent operands and short-circuit. A chain must
+// keep its direction; equality has lower precedence and does not join a chain.
+comparisonExpression
+ : shiftExpression
+ ( (('<' | '<=') shiftExpression)+ | (('>' | '>=') shiftExpression)+ )?
+ ;
+
+shiftExpression
+ : additiveExpression (('<<' | '>>' | '>>>') additiveExpression)*
+ ;
+
+additiveExpression
+ : multiplicativeExpression (('+' | '-') multiplicativeExpression)*
+ ;
+
+multiplicativeExpression
+ : unaryExpression (('*' | '/') unaryExpression)*
+ ;
+
+unaryExpression
+ : ('-' | '!') unaryExpression
+ | updateOperator IDENTIFIER
+ | postfixExpression
+ ;
+
+// Prefix updates require a local name; postfix updates also allow fields
+// and array elements. All updates require an assignable operand.
+updateOperator : '++' | '--' ;
+postfixExpression : primaryExpression postfixSuffix* updateOperator? ;
+postfixSuffix : '.' IDENTIFIER | '[' assignment ']' | '#' | callSuffix | methodCallSuffix ;
+
+// For a fixed function-valued member, object.name(args) passes object as self.
+// This overlaps ordinary field selection followed by a call syntactically;
+// name/type resolution determines which call form applies.
+methodCallSuffix : '.' IDENTIFIER callSuffix ;
+
+primaryExpression
+ : INTEGER_LITERAL
+ | FLOAT_LITERAL
+ | STRING_LITERAL
+ | CHARACTER_LITERAL
+ | 'true'
+ | 'false'
+ | 'null'
+ | IDENTIFIER
+ | '(' assignment ')'
+ | newExpression
+ | functionExpression
+ ;
+
+// A function is an expression bound with an ordinary declaration.
+// Its last statement supplies the implicit result; return can exit earlier.
+functionExpression : '{' parameterList? '->' statement* '}' ;
+parameterList : (parameter ','?)+ ;
+parameter : type bindingModifier? IDENTIFIER ;
+
+// Function types have unnamed, space-separated argument types.
+// {int} is the zero-argument type; its function expression still uses ->.
+functionType
+ : '{' type+ '->' type '}' '?'?
+ | '{' type '}' '?'?
+ ;
+
+callSuffix : '(' argumentList? ')' ;
+argumentList : assignment (',' assignment)* ','? ;
+
+// Allocation invokes the declared constructor; inline initializer blocks from
+// earlier chapters are replaced by constructor arguments.
+newExpression : 'new' type ('[' assignment ']' | callSuffix)? ;
+
+INTEGER_LITERAL : [1-9] DIGIT* | '0' [0-7]* | '0' [xX] HEX_DIGIT+ ;
+FLOAT_LITERAL : DIGIT+ ('.' DIGIT* ('e' DIGIT+)? | 'e' '-'? DIGIT+) ;
+STRING_LITERAL : '"' (ESCAPE | ~["\\])* '"' ;
+CHARACTER_LITERAL : '\'' (ESCAPE | ~['\\]) '\'' ;
+fragment ESCAPE : '\\' [0ntr\\'"] ;
+IDENTIFIER : NON_DIGIT (NON_DIGIT | DIGIT)* ;
+fragment NON_DIGIT : [a-zA-Z_] ;
+fragment DIGIT : [0-9] ;
+fragment HEX_DIGIT : [0-9a-fA-F] ;
+WHITESPACE : [ \t\r\n]+ -> skip ;
+LINE_COMMENT : '//' ~[\r\n]* -> skip ;
+BLOCK_COMMENT : '/*' .*? '*/' -> skip ;
+```
diff --git a/docs/chapter31/31-grammar.md b/docs/chapter31/31-grammar.md
new file mode 100644
index 00000000..9e199d03
--- /dev/null
+++ b/docs/chapter31/31-grammar.md
@@ -0,0 +1,266 @@
+# Grammar for Chapter 31
+
+English | [日本語](31-grammar.ja.md)
+
+The handwritten recursive-descent implementation lives in
+[`com.seaofnodes.simple.parser`](../../src/main/java/com/seaofnodes/simple/parser/).
+The production-to-code map below the grammar describes where syntax ends and
+name resolution or graph construction begins. This grammar documents the
+language; it is not input to a parser generator.
+
+`int` is an alias for `i32`; `i64` explicitly selects 64-bit integer storage.
+This alias does not change literal inference or intermediate arithmetic.
+
+Integer literals use decimal (`42`), leading-zero octal (`052`), or hexadecimal
+(`0x2a`, `0X2A`). Octal digits are 0–7; `08` and `09` are errors. Hexadecimal
+and octal literals can spell any 64-bit bit pattern: the high bit gives the
+usual signed value, so `0xffffffffffffffff` is -1. Decimal literals retain
+their signed 64-bit limit. Floating literals remain decimal, including forms
+such as `012.5` and `012f`. A radix prefix does not change integer inference
+or storage width.
+
+```antlrv4
+grammar SimpleLanguage;
+
+// Name lookup, assignable operands, type compatibility, and access permissions
+// are checked by the compiler in addition to these syntax rules.
+// A file is a compilation unit; its path supplies its namespace.
+// Qualified names resolve nested types and other units without an import keyword.
+program : statement* EOF ;
+
+block : '{' statement* '}' ;
+
+statement
+ : block
+ | returnStatement
+ | ifStatement
+ | whileStatement
+ | forStatement
+ | breakStatement
+ | continueStatement
+ | structDeclaration
+ | declaration ';'
+ | assignment ';'
+ | ';'
+ ;
+
+returnStatement : 'return' assignment ';' ;
+ifStatement : 'if' '(' assignment ')' statement ('else' statement)? ;
+whileStatement : 'while' '(' assignment ')' statement ;
+breakStatement : 'break' ';' ;
+continueStatement : 'continue' ';' ;
+
+forStatement
+ : 'for' '(' (declaration | assignment)? ';' assignment? ';' assignment? ')' statement
+ ;
+
+structDeclaration : 'struct' IDENTIFIER '{' structMember* '}' ';' ;
+structMember : constructorDeclaration | methodDeclaration | statement ;
+
+// The constructor name must match the enclosing struct's name.
+constructorDeclaration : 'new' IDENTIFIER '=' constructorExpression ';' ;
+constructorExpression : '{' constructorParameterList? '->' statement* '}' ;
+constructorParameterList : (constructorParameter ','?)+ ;
+constructorParameter : type '^'? bindingModifier? IDENTIFIER ;
+// ^ accepts an unfinished struct reference; ? independently permits null.
+// Only self may be read while incomplete; ^ arguments may be stored/forwarded.
+// A new result is incomplete iff an actual argument is. See construction-groups.md.
+
+// A method is an ordinary fixed function binding in a struct definition.
+// The compiler supplies self; it is not written in parameterList.
+// Function types and expressions both receive their implicit receiver here.
+methodDeclaration : 'val' bindingModifier? memberName '=' functionExpression ';' ;
+memberName : IDENTIFIER | primitiveType ;
+
+declaration : declarationType binding (',' binding)* ;
+declarationType : type | 'var' | 'val' ;
+binding : bindingModifier? IDENTIFIER ('=' initializer)? ;
+// At class/struct member scope, memberName also permits primitive type names.
+// Ordinary locals and parameters still require a non-keyword IDENTIFIER.
+bindingModifier : '!' | '~' ;
+// A declaration initializer consisting of the string "C" declares an extern
+// symbol: a function, or explicit numeric native storage. Ordinary strings
+// elsewhere are array-valued expressions.
+initializer : assignment ;
+
+// The prefix qualifies the named struct reference, not an enclosing array.
+// Each [] / [~] suffix qualifies its own array layer. ? requires a reference.
+type
+ : functionType
+ | primitiveType typeSuffix*
+ | accessModifier? typeName typeSuffix*
+ ;
+accessModifier : '!' | '~' ;
+typeSuffix : '?' | '[]' | '[~]' ;
+typeName : IDENTIFIER ('.' IDENTIFIER)* ;
+
+primitiveType
+ : 'int' | 'i8' | 'i16' | 'i32' | 'i64'
+ | 'u1' | 'u8' | 'u16' | 'u32' | 'byte' | 'bool'
+ | 'flt' | 'f32' | 'f64'
+ ;
+
+assignment : expression (assignmentOperator assignment)? ;
+assignmentOperator
+ : '=' | '+=' | '-=' | '*=' | '/=' | '&=' | '|=' | '^='
+ | '<<=' | '>>=' | '>>>='
+ ;
+
+expression : logicalExpression ('?' assignment (':' assignment)?)? ;
+
+// Both logical operators short-circuit and associate to the right here.
+logicalExpression : bitwiseExpression (('&&' | '||') logicalExpression)? ;
+
+bitwiseExpression
+ : equalityExpression (('&' | '|' | '^') equalityExpression)*
+ ;
+
+equalityExpression : comparisonExpression (('==' | '!=') comparisonExpression)* ;
+
+// Chained comparisons test adjacent operands and short-circuit. A chain must
+// keep its direction; equality has lower precedence and does not join a chain.
+comparisonExpression
+ : shiftExpression
+ ( (('<' | '<=') shiftExpression)+ | (('>' | '>=') shiftExpression)+ )?
+ ;
+
+shiftExpression
+ : additiveExpression (('<<' | '>>' | '>>>') additiveExpression)*
+ ;
+
+additiveExpression
+ : multiplicativeExpression (('+' | '-') multiplicativeExpression)*
+ ;
+
+multiplicativeExpression
+ : unaryExpression (('*' | '/') unaryExpression)*
+ ;
+
+unaryExpression
+ : ('-' | '!') unaryExpression
+ | updateOperator IDENTIFIER
+ | postfixExpression
+ ;
+
+// Prefix updates require a local name; postfix updates also allow fields
+// and array elements. All updates require an assignable operand.
+updateOperator : '++' | '--' ;
+postfixExpression : primaryExpression postfixSuffix* updateOperator? ;
+postfixSuffix : '.' memberName | '[' assignment ']' | '#' | callSuffix | methodCallSuffix ;
+
+// For a fixed function-valued member, object.name(args) passes object as self.
+// This overlaps ordinary field selection followed by a call syntactically;
+// name/type resolution determines which call form applies.
+methodCallSuffix : '.' memberName callSuffix ;
+
+primaryExpression
+ : INTEGER_LITERAL
+ | FLOAT_LITERAL
+ | stringExpression
+ | CHARACTER_LITERAL
+ | 'true'
+ | 'false'
+ | 'null'
+ | IDENTIFIER
+ | '(' assignment ')'
+ | newExpression
+ | functionExpression
+ ;
+
+// A function is an expression bound with an ordinary declaration.
+// Its last statement supplies the implicit result; return can exit earlier.
+functionExpression : '{' parameterList? '->' statement* '}' ;
+parameterList : (parameter ','?)+ ;
+parameter : type bindingModifier? IDENTIFIER ;
+
+// Function types have unnamed, space-separated argument types.
+// {int} is the zero-argument type; its function expression still uses ->.
+functionType
+ : '{' type+ '->' type '}' '?'?
+ | '{' type '}' '?'?
+ ;
+
+callSuffix : '(' argumentList? ')' ;
+argumentList : assignment (',' assignment)* ','? ;
+
+// Allocation invokes the declared constructor; inline initializer blocks from
+// earlier chapters are replaced by constructor arguments.
+newExpression : 'new' type ('[' assignment ']' | callSuffix)? ;
+
+INTEGER_LITERAL : [1-9] DIGIT* | '0' [0-7]* | '0' [xX] HEX_DIGIT+ ;
+// The f suffix also accepts an integer-shaped token, e.g. 12f.
+FLOAT_LITERAL : DIGIT+ ('.' DIGIT* ('e' DIGIT+)? | 'e' '-'? DIGIT+) 'f'?
+ | DIGIT+ 'f' ;
+// String text uses a lexical mode: whitespace/comments are literal here.
+// Interpolation re-enters the ordinary expression grammar, recursively.
+stringExpression : '"' (STRING_TEXT | ESCAPE | '%%' | interpolation)* '"' ;
+interpolation : '%' format? '(' assignment ')' ;
+format : FORMAT_FLAG* FORMAT_WIDTH? ('.' FORMAT_WIDTH?)? FORMAT_KIND TIME_STYLE? ;
+// Flags: - + space # 0. Width/precision: decimal digits, no expressions.
+// Kinds: d i u o x X f F e E g G a A c s b t.
+// TIME_STYLE: F r R M D T, allowed only after t; default F (ISO UTC).
+// Time styles: ISO, 12-hour, 24-hour, elapsed MM:SS.mmm, DD/MM/YY, HH:MM:SS.
+// Duplicate/inapplicable flags and precision on c/b/t are rejected.
+// Time accepts width and left alignment only; see time.md.
+// STRING_TEXT excludes double quote, backslash, and percent.
+CHARACTER_LITERAL : '\'' (ESCAPE | ~['\\]) '\'' ;
+fragment ESCAPE : '\\' [0ntr\\'"%] ;
+IDENTIFIER : NON_DIGIT (NON_DIGIT | DIGIT)* ;
+fragment NON_DIGIT : [a-zA-Z_] ;
+fragment DIGIT : [0-9] ;
+fragment HEX_DIGIT : [0-9a-fA-F] ;
+WHITESPACE : [ \t\r\n]+ -> skip ;
+LINE_COMMENT : '//' ~[\r\n]* -> skip ;
+BLOCK_COMMENT : '/*' .*? '*/' -> skip ;
+```
+
+## Reading the parser alongside the grammar
+
+All paths below are within `src/main/java/com/seaofnodes/simple/parser/`.
+The components share one `Parser` instance containing the cursor, active scope,
+compilation unit, and graph utilities. They are package-private collaborators;
+there is no inheritance chain, AST, or additional compiler pass.
+
+| Productions | Implementation |
+| --- | --- |
+| `program` | [`Parser.parse`](../../src/main/java/com/seaofnodes/simple/parser/Parser.java): establish the source-unit scope, parse its initializer, resolve remaining file names, publish its class |
+| `statement`, `block`, loops, jumps, `returnStatement` | [`Statements`](../../src/main/java/com/seaofnodes/simple/parser/Statements.java), starting at `parseStatement` |
+| `declaration`, `binding`, `bindingModifier` | [`Declarations`](../../src/main/java/com/seaofnodes/simple/parser/Declarations.java): declaration/expression lookahead, then each binding and initializer |
+| `structDeclaration`, `constructorDeclaration` | [`Structs`](../../src/main/java/com/seaofnodes/simple/parser/Structs.java), starting at `parseStruct` and `parseConstructorDeclaration`; field discovery and initialization checks follow these entry points |
+| `type`, `typeName`, `functionType` | [`Types`](../../src/main/java/com/seaofnodes/simple/parser/Types.java): `parseType` and `parseFunctionType`; qualified-name lookup lives in `typeName`, `dottedName`, and `rootedName` |
+| `assignment` through `primaryExpression` | [`Expressions`](../../src/main/java/com/seaofnodes/simple/parser/Expressions.java): `parseAssignment`, `parseExpression`, `parseLogical`, `parseBitwise`, `parseEquality`, `parseComparison`, `parseShift`, `parseAddition`, `parseMultiplication`, `parseUnary`, `parsePrimary` |
+| `postfixExpression`, member selection and updates | `Expressions.parsePostfix` / `parsePostfixMethod` dispatch to [`References.parseField`](../../src/main/java/com/seaofnodes/simple/parser/References.java); `References.parseName` handles bare names, implicit receivers, and forward references |
+| `functionExpression`, `parameterList`, calls, `argumentList` | [`Functions`](../../src/main/java/com/seaofnodes/simple/parser/Functions.java): `parseFunction`, `parseConstructor`, shared `parameters` and `arguments`, then `parseBody` |
+| `newExpression` | [`Allocations.parseNew`](../../src/main/java/com/seaofnodes/simple/parser/Allocations.java); the allocation-helper graph builder follows the expression routines |
+| `stringExpression`, `interpolation` | [`Strings.parse`](../../src/main/java/com/seaofnodes/simple/parser/Strings.java), using `Lexer.stringPart` / `stringFormat`; normal expression parsing resumes inside each interpolation |
+| Literals, identifiers, comments, token matching | [`Lexer`](../../src/main/java/com/seaofnodes/simple/parser/Lexer.java); it also supplies source-location snapshots used by diagnostics and serialized IR |
+
+`ControlFlow` shares guarded scope merging between `if`, `?:`, `&&`, and `||`.
+`Conversions` shares binding, field, and result conversions. These
+operations are compiler semantics rather than additional grammar productions.
+`Parser` retains the type registry and the small control/memory/peephole helpers
+used by several productions and by the rest of the compiler.
+
+There are a few deliberate differences between a syntax-only parser and this
+one-pass IR builder:
+
+- A statement beginning with `{` might be a block or a function-type binding.
+ `Declarations` and `Types` use speculative parsing and cursor restore;
+ qualified type lookup also stops before a suffix that belongs to an expression.
+- Bare assignment, field assignment, and compound updates share the grammar's
+ precedence but use different IR paths. `References` decides between an SSA
+ local, a constructor field's local value, and an actual memory access. A
+ function-valued field may also supply an implicit receiver to a call.
+- `for` updates are scanned first and parsed after the body, where their effects
+ belong. Explicit constructor bodies are scanned first and parsed after field
+ discovery, so later fields have local slots. Both use `Lexer.skipTo`, which
+ ignores delimiters inside comments and quoted text.
+- `Structs.parseInitializer` parses a file or struct body as a function while
+ discovering fields. `Functions.parseBody` still owns function scope/return
+ construction; moving it does not change constructor or memory semantics.
+
+The syntax chart does not describe all name/type restrictions. In particular,
+prefix updates require an already-known non-pointer binding, and a private-class
+privacy policy is separate from recognizing a qualified type name. The existing
+same-named function binding inside a struct is also recognized as a constructor;
+`new Name = { ... };` is the explicit constructor spelling above.
diff --git a/docs/chapter31/README.ja.md b/docs/chapter31/README.ja.md
new file mode 100644
index 00000000..eaa11cd1
--- /dev/null
+++ b/docs/chapter31/README.ja.md
@@ -0,0 +1,20 @@
+# 第31章:ソフトウェア描画とグラフィカルゲーム
+
+[English](README.md) | 日本語
+
+[前の章:第30章 — テキスト整形と診断](../chapter30/README.ja.md)
+
+第30章で完成したコンパイラと標準ライブラリを出発点とする章です。
+Simple で描画する `sys.draw.Canvas` と、MiniFB を使うオプションの
+ウィンドウ・入力 API `sys.gfx` を使って、グラフィカルな三目並べを実装します。
+矩形、円、太い線、色、ビットマップ文字を扱えます。
+詳細は[英語版の描画ライブラリとゲームの説明](drawing.md)を参照してください。
+通常のビルドとテストに MiniFB や画面表示は必要ありません。
+
+```sh
+make -C chapter31 run-graphical-tictactoe CTAGS=
+```
+
+このスナップショットの[文法](31-grammar.ja.md)と
+[実行可能な例](examples/)を用意しています。
+ビルド手順は[英語版](README.md)を参照してください。
diff --git a/docs/chapter31/README.md b/docs/chapter31/README.md
new file mode 100644
index 00000000..ef6b0cdc
--- /dev/null
+++ b/docs/chapter31/README.md
@@ -0,0 +1,62 @@
+# Chapter 31: Software Drawing, Time, and Graphical Games
+
+English | [日本語](README.ja.md)
+
+[Previous: Chapter 30 — Text Formatting and Diagnostics](../chapter30/README.md)
+
+Small graphical games drive this chapter's language and library work, starting
+with a pixel window for a Simple-written 2-D graphics library. MiniFB supplies
+the optional native window/input backend; drawing stays in Simple.
+
+The [drawing library](drawing.md) provides a pixel canvas, colors,
+rectangles, circles, thick lines, and bitmap labels. The
+[graphical tic-tac-toe game](examples/TicTacToeGraphical.smp) uses it for
+mouse/keyboard play against the terminal game's CPU strategy.
+The optional [audio library](audio.md) adds soft move sounds and rising
+or falling win/loss cues, played in the background by miniaudio.
+Hexadecimal colors (`0xe47b57`) and leading-zero octal font masks (`025755`)
+are supported by this chapter's integer literal syntax.
+
+The [time library](time.md) adds millisecond clocks, calendar conversion,
+absolute ISO parsing, built-in time formatting, sleeps, and cooperative timers.
+The [digital and analog clock](examples/Clock.smp) demonstrates single-threaded
+animation and the reusable `sys.draw.Button` widget:
+`make -C chapter31 run-clock CTAGS=`. Click its button or press Space to switch
+between green seven-segment digits and an analog dial.
+
+```sh
+make -C chapter31 libs CTAGS= # Once while online (also: init or lib).
+make -C chapter31 run-graphical-tictactoe CTAGS=
+```
+
+This explicitly builds the optional graphics/audio backends and opens a window. Use
+`graphical-tictactoe` instead to build the executable without starting it or
+requiring MiniFB.
+Dependencies stay cached under `chapter31/lib`, including across `make clean`.
+Only explicit `libs`/`init`/`lib` setup targets access the internet; other
+targets use local files and fail promptly if something needs downloading.
+
+The default build and test suite need no graphics dependencies. See
+[optional graphics](graphics.md) for building MiniFB, running the pixel
+example, and handling a missing backend. `make tests_nographics CTAGS=` runs
+all ordinary tests with graphics explicitly disabled.
+
+The inherited language and library are described in [Chapter 30](../chapter30/README.md).
+The [grammar and code map](31-grammar.md),
+[source layout](../../src/main/java/com/seaofnodes/simple/README.md), and
+[runnable examples](examples/) describe this snapshot.
+
+From the repository root:
+
+```sh
+make lib tests release CHAPTERS=chapter31 CTAGS=
+make -C chapter31 tictactoe CTAGS=
+```
+
+The game binary is `chapter31/build/examples/x86_64_v2_win64/TicTacToe.exe`
+on Windows and `chapter31/build/examples/x86_64_v2_SystemV/TicTacToe` on Linux.
+For a WSL build in the same checkout, use `CLZDIR=build/classes-linux` to keep
+Linux Java outputs separate from Windows outputs.
+
+IntelliJ has a `chapter31` module and a **chapter31 tests** run configuration.
+Inherited Java test names retain their historical chapter numbers.
diff --git a/docs/chapter31/audio.md b/docs/chapter31/audio.md
new file mode 100644
index 00000000..30817012
--- /dev/null
+++ b/docs/chapter31/audio.md
@@ -0,0 +1,62 @@
+# Optional background audio
+
+`sys.audio` uses [miniaudio](https://github.com/mackron/miniaudio) through an
+optional native C backend. Playback runs on its own thread; the game keeps
+processing input and drawing. No Python, CMake, codec packages, or sound files
+are needed. Default builds/tests never download miniaudio or open a device.
+
+From `chapter31`:
+
+```sh
+make libs CTAGS= # Once while online, or use make init.
+make audio CTAGS=
+make run-graphical-tictactoe CTAGS=
+```
+
+The last command builds both optional backends and runs the game. Accepted
+moves get a quiet 70ms sine boop. A player win gets a 480ms rising C-E-G
+celebration; a CPU win reverses the notes. Invalid input and occupied squares
+are silent. Replay stops any previous cue. Win/loss cues replace the ordinary
+move boop; draws use the ordinary boop.
+
+For direct execution in PowerShell, alongside `SIMPLE_MINIFB`:
+
+```powershell
+$env:SIMPLE_MINIAUDIO = (Resolve-Path build/release/x86_64_v2_win64/simple_miniaudio.dll).Path
+.\build\examples\x86_64_v2_win64\TicTacToeGraphical.exe
+```
+
+Linux uses `libsimple_miniaudio.so` in the corresponding release directory.
+`SIMPLE_MINIAUDIO` must name the full backend path. Unset it or set it to `off`
+for silent play. An unavailable audio device returns `ERROR` and the game
+continues. Audio and graphics can be configured independently.
+
+| Operation | Meaning |
+| --- | --- |
+| `open()` | Open output and generate built-in sounds; `OK` (0) or `ERROR` (-1). Repeated open is safe. |
+| `boop()`, `win()`, `lose()` | Play a built-in cue; same as `play(BOOP/WIN/LOSE)`. |
+| `load(samples)` | Copy `i16[~]` mono PCM at `RATE` (48000Hz); return handle or `ERROR`. |
+| `play(handle)` | Start/restart, replacing the current sound. Return `OK` or `ERROR`. |
+| `stop()` | Stop playback; safe before open/after close. |
+| `playing()` | Whether samples remain to submit; final device buffering may remain. |
+| `close()` | Join the audio thread and free device/buffers; safe to repeat. |
+
+All calls belong on the main/game thread. Load custom sounds at startup:
+there are 28 custom slots, each up to ten seconds, retained until close.
+Custom handles must be reloaded after close/reopen. Signed samples range
+from -32768 to 32767; quiet effects should use much smaller amplitudes.
+Native code copies the array and never retains a Simple heap pointer or calls
+Simple from the audio thread. Playback itself neither allocates nor waits for
+the sound duration. This first interface plays one sound at a time.
+
+`audio.mk` pins miniaudio 0.11.23, cached under `lib/miniaudio-0.11.23/` by
+`make libs` (or `init`; `lib` remains a setup alias). Only these explicit setup
+targets can download dependencies. All builds, runs, and tests use local
+copies, surviving `make clean`; missing cache entries fail promptly with setup
+instructions. Windows uses the existing MinGW-w64 compiler;
+Linux uses GCC and system pthread/math/dl libraries. No audio development
+headers need installing. The upstream header includes its public-domain and
+MIT-0 license options, copied beside the backend as `miniaudio-LICENSE`.
+`make test-audio CTAGS=` builds the backend checks from the local cache
+and runs a real audio thread with miniaudio's silent null
+device; normal tests exercise the loader and game without hardware.
diff --git a/docs/chapter31/construction-groups.md b/docs/chapter31/construction-groups.md
new file mode 100644
index 00000000..197a6082
--- /dev/null
+++ b/docs/chapter31/construction-groups.md
@@ -0,0 +1,241 @@
+# Construction: field types and incomplete references
+
+Instance constructors use ordinary field Loads and Stores. There are two checks:
+whether a field has a value, and whether an object reference is usable outside
+construction. Neither requires a collection of constructor-group identities.
+
+## Field initialization
+
+New produces private `TypeMem` containing the object's `TypeStruct`, with
+`TypeScalar.BOT` for fresh fields. Defaults emit ordinary Stores: zero for an
+ordinary integer, null for a nullable pointer, no Store for a required field.
+An assignment strongly updates its field in private memory. Phi meets the
+incoming memories; a missing live arm survives because `BOT.meet(7) == BOT`.
+An initialized unknown integer has `TypeInteger.BOT`, not `TypeScalar.BOT`.
+Arrays retain their language-level zero filling.
+
+One `#selfMem` binding carries this memory through scopes and returns. A Load
+checks its incoming field type. Constructor completion checks the merged Return
+memory; the explicit constructor receives the declaration initializer's merged
+result, including early exits. There is no backwards initialization query,
+per-field parser state, or store-all instance exit.
+
+The existing Iter/Opto passes propagate these types; TypeCheck reports errors.
+There is no extra construction type solve or retained source-use list. An
+initialized field carried unchanged around a loop needs the optimistic SCCP
+solution. Numeric fields in loop memory widen as scalar loop values do.
+Arithmetic, conversions, and numeric Stores preserve Scalar.BOT, so a live bad
+read cannot disappear merely because it is multiplied by zero. Genuinely unused
+reads may be removed by DCE.
+
+The allocator's ConstructNode retains the constructor's final private memory
+for its completion check. TypeCheck gathers these nodes during its existing
+error walk and removes checked boundaries before lowering. The pass removal
+exposes known failures; see [the late-check notes](deferred-construction.md).
+
+Internal construction memory parameters use actual allocation/initializer call
+edges; the hypothetical external caller does not supply their memory. They
+retain their memory family when there are no live callers. Function-valued
+fields retain their declared signatures in private summaries, avoiding repeated
+unfolding of a constructor whose returned memory contains its own function field.
+Class singleton initialization keeps its older store-at-exit implementation.
+
+## Partial references and readable fields
+
+`self` and incoming `C ^arg` parameters are incomplete references. `C? ^arg`
+also accepts null. Ordinary functions and constructors accept the qualifier on
+struct parameters; arrays cannot be qualified this way.
+
+- An allocation result is incomplete if any actual argument is incomplete.
+- With only completed arguments, including null, the result is complete.
+- An incomplete reference may be stored into self or passed to a `^` parameter.
+- Ordinary calls and stores into other objects require completed references.
+- A borrowed partial can read fields its incoming type proves initialized.
+ It cannot write fields, even non-finals. Exact local aliases of self retain
+ the original constructor's access to its current private memory.
+- An object-reference field read through a partial is conservatively
+ incomplete, with no initialized-field guarantees for its referent. There is
+ no inference of initialized-field requirements on `^` arguments.
+ Arrays are already initialized and cannot receive
+ incomplete elements, so an initialized array field can be used normally.
+
+For example:
+
+```simple
+struct Child {
+ Test.Parent? parent;
+ new Child = { Test.Parent? ^par -> parent = par; };
+};
+struct Parent {
+ Child child;
+ int~ payload;
+ new Parent = { int n ->
+ child = new Child(self); // Incomplete result: self is incomplete.
+ // Retention does not authorize reading parent.payload through child.
+ payload = n;
+ };
+};
+val parent = new Parent(arg);
+val local = new Child(parent); // Complete argument, complete result.
+return local.parent==parent; // Lookup is allowed.
+```
+
+Here `Test` names the source unit. After the root Parent constructor completes,
+the entire cycle is usable: all members have initialized their own fields, and
+none could expose an incomplete reference to ordinary code during construction.
+The Child constructor still treats its `^par` as incomplete when the actual
+argument is complete. This public constructor also has unknown callers, whose
+partial arguments promise no initialized fields, so `par.payload` is unreadable.
+
+## Forward field views
+
+At a use of self, `InitViewNode` combines the pointer with that program point's
+private memory. Its TypeMemPtr contains declared types for initialized fields
+and Scalar.BOT for missing fields. The node retains the declaration separately
+from the incoming flow shape, so an exact self alias captured before a Store
+can receive a fresh view afterward. Earlier calls retain their earlier views.
+
+For example, a private helper can read `x` while `y` is still unset:
+
+```simple
+struct _C {
+ int~ x;
+ int~ y;
+ new _C = { int n -> x=n; y=_read(self); };
+};
+val _read = { _C ^p -> return p.x; };
+return new _C(arg).y;
+```
+
+Parm meets the incoming shapes using the normal type flow. If any live caller
+lacks `x`, Scalar.BOT survives and the Load reports the missing initialization.
+Unknown callers supply an empty field promise. Parsing must also start borrowed
+parameters at that bound: otherwise early folding of `p.x*0` could hide a bad
+read before SCCP. The hidden constructor receiver retains its declaration;
+its private memory supplies its read checks and precise field updates.
+
+Values still come from normal memory. Before a call borrowing self, EscapeNode
+borrow slices carry the current constructor Stores onto the ordinary call
+memory edge. These slices are aliasable and do not publish the private identity
+or its fields into the global escape sets. No hidden partial-memory arguments,
+field bitsets, additional passes, or recursive flow queries are introduced.
+
+InitView folds before TypeCheck when its input already has the same type and
+retains a structural partial view. Nested partial-only Construct wrappers also
+collapse. A Parm's current partial type alone is insufficient: inlining may
+later substitute a complete actual argument, which must not grant the borrowed
+parameter permission to write or publish that object. The completion form of
+Construct already folds once its memory proves initialization and its result
+type matches its input.
+
+TypeCheck checks the consumers and erases remaining InitViews along with the
+checked Construct boundaries in its existing walk. A surviving field view is
+not necessarily erroneous: a reader may need only the initialized subset.
+Construct remains necessary for
+final-field completion and for the partial qualifier of each source `new`.
+Alias Phis and nested reference loads are conservative; they do not acquire
+the original constructor's write permission. This prototype adds no general
+ban on overlapping allocations of the same class: only the owning receiver
+uses private memory, while borrowed reads use aliasable memory.
+
+## Aliases and loops
+
+Incomplete is a property of a value, not just the current contents of a parser
+Var. Consider `while (n--) { use(p); p = self; }`: the backedge introduces an
+incomplete value after `use(p)` has been parsed. `TypeMemPtr._partial` carries
+that restriction through ordinary forward typing. It is an independent
+two-point lattice: meet ORs the restriction and dual complements it. Copies,
+conversions, Stores, and Phis preserve it; a merge is incomplete if any live
+input is incomplete. A `^` formal stays incomplete even when its known callers
+are complete. The receiver's private memory identifies the own-self exception;
+another incomplete pointer of the same class has no such permission.
+
+A small `ConstructNode` wraps each source `new`. Its runtime value is the
+allocator result; its type adds the OR of that call's actual argument qualifiers.
+This distinguishes `new Child(self)` from `new Child(null)` even when both call
+the same constructor. Looking only at the shared callee Return would lose this
+distinction. The node disappears after TypeCheck, before lowering.
+
+A completed receiver gives a completed view of its pointer fields. Construction
+may retain incomplete references but cannot publish them, so reaching the root
+`new` boundary with complete arguments makes the entire reachable cycle usable.
+There is no group identity or traversal to mark members complete. Conversely,
+object-reference fields read through incomplete self remain conservative, even
+if the particular stored reference was complete.
+
+The existing optimistic SCCP solve also handles qualifier cycles. For
+`p = null; while (n--) p = new Link(p);`, no input introduces incompleteness,
+so `p` stays complete and a subsequent field read is allowed. A backedge carrying
+self instead makes the Phi and its uses incomplete.
+
+`Construction` selects private self memory during parsing; it retains no
+source-use lists or flow facts and performs no recursive Load/Phi/Call query. The old
+root and allocation bitsets are gone. There are no field-requirement sets,
+hidden `^` memory arguments, or runtime group objects.
+
+`PartialReadTest` exercises field reads, missing fields, forwarding, merged
+callers, loops, exact aliases, constructor retention, and borrowed write and
+publication restrictions. `ConstructionGroupTest` exercises cycles, complete/null arguments, prohibited
+lookups/publication, aliases, branch merges, complete and incomplete loop
+backedges, and declared restrictions with only complete callers, including an
+uninlined constructor through machine encoding. `ConstructorCompletionTest`,
+`FinalConstructionTest`, and `BoundaryTest` exercise field-memory semantics.
+The former inferred-requirement litmus tests are preserved, explicitly deferred,
+in `ConstructionRequirementsTest`. Separate compilation of `^` functions is
+still deliberately unsupported. The pointer qualifier has an object-file
+representation, but importing and checking the full construction contract is
+outside this experiment. Rebuild chapter 29 object files after the type-format
+change.
+
+## Deferred field-aware contracts (2026-11-07)
+
+Decision: do not implement inferred contracts now. Cliff considers the required
+infrastructure too large for the feature alone; revisit when a general
+backwards/liveness flow is justified, potentially for memory management.
+The later forward-field-view prototype above does not implement this contract
+discovery proposal. Separate compilation of `^` interfaces remains rejected.
+
+The intended extension would let functions and constructors receive partially
+initialized objects, read initialized fields, retain partial references with
+their restrictions, and write non-final fields. Only the original constructor
+would retain permission to initialize/reassign its own final fields; passing a
+pointer must not pass that permission. Final-field reads during construction
+would not establish value stability against later writes by that constructor.
+
+The proposed contract representation uses normal types, not a separate field
+bitset. For a function reading only `P.pi`, its unknown-caller default would be
+schematically `Partial{pi:declared-pi-type, name:TypeScalar.BOT}`. Scalar.BOT
+means no initialization guarantee, not a requirement that the caller leave the
+field unset. Required fields receive their declared types, covering every legal
+external caller. MEET with actual arguments preserves those guarantees only
+when the callers satisfy them; extra initialized fields supplied by locals do
+not strengthen the body beyond the external contract. TypeStruct/Field already
+meet field contents, and nested/cyclic types can represent nested contracts.
+TOP is not the missing-initialization marker.
+
+Cliff proposed discovering conservative, context-insensitive requirements after
+Iter and before SCCP. Freeze the resulting default argument types on functions;
+SCCP consumes them without refining the contracts. Reads on branches SCCP later
+eliminates may still impose requirements, as may reads following assignments.
+That rejection of some valid programs is acceptable for an opt-in `^` feature.
+Combine compatible requirement shapes with JOIN; combine possible caller states
+with MEET. Keep the contract distinct from the merged Parm type, and check each
+actual argument against it for a call-site diagnostic such as
+"foo requires Partial.pi to be initialized". Serialize contracts with declaration
+metadata so checking imported calls does not require loading bodies.
+
+The complication is discovering and propagating those contracts. Local SSA
+aliases share values; Phi demands reach every input. A demand on a loaded field
+becomes a nested receiver requirement. Calls substitute actual arguments for
+formals, and recursive forwarding needs a convergent propagation rule. Cyclic
+types represent recursive requirements but do not by themselves prevent a
+discovery algorithm from unfolding progressively deeper paths. Indirect calls
+need contracts available through their callable interfaces; returns need usable
+guarantees without retroactively strengthening an exported contract for a client.
+Stored references and helper writes also require a coherent account of private
+construction memory and object identity; that discussion was deliberately left
+unfinished. None of this requires H-M or bidirectional type inference, but it
+does require a substantial backwards demand analysis to handle well.
+
+The ignored ConstructionRequirementsTest retains useful litmus cases, not an
+approved implementation plan. Do not restart this work without a new request.
diff --git a/docs/chapter31/deferred-construction.md b/docs/chapter31/deferred-construction.md
new file mode 100644
index 00000000..104ab9e9
--- /dev/null
+++ b/docs/chapter31/deferred-construction.md
@@ -0,0 +1,385 @@
+# Deferring construction checks: experiment
+
+The [escape-flow continuation](escape-flow.md) supersedes the frozen-public
+fallback and remaining recursive-test failures described later in these notes.
+All four recursive examples now pass their original printed-IR expectations.
+The older audit below records the evidence that led to this change.
+
+The extra construction pass is removed from the working implementation.
+`Construction.check()`, its retained source-use lists, `Iter.types()`, and the
+separate constructor-Return walk are gone. Errors now belong to the existing
+TypeCheck walk; ConstructNode retains final private memory for completion checks.
+No additional whole-graph pass was added. Constructor rejection tests remain active.
+
+## Constant folding
+
+`TypeScalar.BOT` is not an initialized integer. Arithmetic, comparisons,
+negation, numeric conversions, and typed Stores must preserve it rather than
+manufacturing an integer or floating-point result. In particular, `x*0` and
+`x==x` cannot become constants while `x` has this type. An initialized unknown
+integer still permits those folds.
+
+The scan found and changed these related cases:
+
+- Multiplication by zero now folds in `compute`, after checking numeric inputs.
+ Integer strength reduction also waits for integer operands; an unfinished
+ loop Phi could eventually become a float.
+- Same-input comparisons fold in `compute`. Floating comparisons keep NaN
+ semantics; `x==x` is not necessarily true. The integer range implementation
+ of `!=` also had the equality answers reversed.
+- Arithmetic pushed through Phis uses each copied operation's `compute`,
+ including floating-point constants, instead of an unchecked integer cast.
+- Known array length offsets and element shifts fold in
+ `ConFldOffNode.compute`, rather than its `idealize`.
+- `Not`, unary minus, conversions, and Stores preserve unknown initialization.
+ `RoundF32` already passed that type through.
+
+This does not ban creating constant *operands* in rewrites such as `x*8` to
+`x<<3`. Memory forwarding still has cases that return a stored constant or an
+array's zero-filled element; these were inspected but not rewritten into a new
+memory analysis. `Load.idealize` retains a `Scalar.BOT` load.
+
+The changes exposed a rejected-Phi-fold worklist cycle. Factoring
+`Phi(bool & 7, integer & bool)` widens the result from `bool` to `[0-7]` and
+must fail. Building and then killing the candidate woke the same dependencies
+forever. The trial now computes its types before registering use edges.
+
+## Preserving the constructor body through optimization
+
+This regression must reject a live incomplete lookup:
+
+```simple
+struct Child {
+ Test.Parent? parent;
+ new Child = { Test.Parent? ^par -> parent = par; };
+};
+struct Parent {
+ Child child;
+ int~ payload;
+ new Parent = { ->
+ child = new Child(self);
+ payload = child.parent == null;
+ };
+};
+return new Parent().payload;
+```
+
+`child` is incomplete because its constructor received incomplete `self`.
+Reading `child.parent` must therefore fail. The read supplies the final result;
+this is not a discarded local or dead-code issue.
+
+The decisive failure was private-memory Parm typing. During Iter, inlining
+removed a real caller while the function still had its unknown-caller edge.
+The special case returned MemTop anyway. That erased the memory merge, Store,
+and the bad Load feeding it, before TypeCheck could report the error.
+
+Parms now use their normal merge. Hidden initializer/constructor helpers have
+only actual allocator callers; the source-visible constructor binding denotes
+the allocator. Public allocators still receive unknown callers, so unused public
+constructor bodies remain checked. This also avoids poisoning a helper's private
+memory with a fictitious external default.
+
+Inlining presents a second issue: a complete actual pointer must not grant
+permission to read a restricted formal. A partial-only ConstructNode gives
+self and ^ parameters their body view independently of Parm typing. It remains
+until TypeCheck. Load's alias search recognizes that this view preserves the
+address, while compute/err continue to see the qualified pointer.
+
+A smaller regression reads an unwritten field through a self cycle:
+
+```simple
+struct C {
+ C next = self;
+ int~ x;
+ new C = { -> x = next.x; };
+};
+return new C().x;
+```
+
+Both examples now reject at TypeCheck. A positive regression initializes x to
+7, increments it in the constructor, and checks that the result folds to 8.
+
+The broader run exposed a function-lifetime bug during SCCP: unlinking calls
+could delete a body subsequently rediscovered from a type-level function
+reference. Returns are kept during the existing reset walk and released once
+call edges have been rebuilt. No additional graph walk is needed.
+
+Class-init dependency discovery also skips a CLInit whose receiver has not
+resolved to a class. A Scalar.BOT receiver is left for its Load's TypeCheck
+error, rather than dereferencing a null class name.
+
+The trial successfully uses the existing passes to prove a chain built from
+`null` complete. It also rejects direct `par.payload`, `par.payload*0`, and
+`int x = par.payload; value = x*0` after the arithmetic/conversion repairs.
+
+Genuinely unused reads are allowed to disappear under late-check semantics.
+Rejection tests must keep the read live. `testUnusedIncompleteReadCanDie` checks
+that an unused `par.payload` read is eliminated and the program returns 5.
+
+## Optimization differences and register allocation
+
+`Chapter18Test.testFcn9` exposed an independent escape bug. StopCU treated the
+class initializer's synthetic self as an object already held by an external
+caller. Its broad function-valued field type exposed the local x function even
+though the initializer never returned and x had no callers after inlining.
+Skipping that synthetic argument restores the empty escape set for x and the
+expected `return Top;`. Actual public-call arguments still escape normally.
+
+That correction exposed `Chapter18Test.testFcn8`: a Store computed from TOP
+memory interpreted its optimistic singleton bit as established private memory.
+It widened a function value to the declared signature, then narrowed its FIDX
+set when memory resolved. Store now returns TOP while its memory is TOP, so
+SCCP remains monotone.
+
+The String differences have a different cause. In Chapter19, the private
+_hashCodeString body dies; the surviving functions are the initializer,
+allocator, equals, and public hashCode. Its golden string now omits the two dead
+private-helper MemTop Returns and matches the current addition order.
+`Add.spine_cmp` breaks this tie by node ID. Strength reduction creates the Sub
+in IterPostOpto, after the byte Load (Sub#594 versus Load#504 in one traced copy),
+so the Sub sorts to the left. This is node ordering, not a lost value type.
+
+In the Chapter20/21 fixtures, _hashCodeString really escapes through the implicit
+return, and hashCode contains an inlined copy. Before the allocator repair, both loops needed an extra x86
+register copy with the new addition operand order. A disposable probe
+swapped only `((hash<<5)-hash) + byte` to `byte + ((hash<<5)-hash)` after Unlink:
+no types or live functions changed, but the weighted split score fell from 37
+to 21. Each hash body fell from 17 to 9; the allocator stayed at 3.
+
+Reconstructing the earlier source by reversing the saved construction-pass
+removal patch reproduces the expected 29. Its standalone _hashCodeString costs
+17, inlined hashCode costs 9, and allocator costs 3. Before the allocator repair,
+these costs were 17, 17, and 3. Both versions contain two hash loops; no inlining decision changed.
+Current code emits two fewer functions, the dead initializer/constructor helpers,
+which contributed zero. Thus the entire increase is one loop-weighted register
+copy in the inlined hashCode body, not another retained function or stack spill.
+
+With the old operand order, the byte Load can write the loop's hash register
+and Add accumulates the shifted/subtracted value into it. With the new order,
+Add's destructive input is the Sub result. Its two-address tie plus the Phi
+backedge joins the shift/Sub/Add chain to the old hash live range, which overlaps
+the shifted value at the subtraction. `RegAlloc.splitSelfConflict` inserts a
+`use/self/two` copy before Add. The existing commute heuristic in `splitByLoop`
+is not reached: self conflicts take an earlier splitting path. Commuting Add
+before splitting is the concrete optimization opportunity.
+
+The allocator now does this in `splitSelfConflict`, using the existing failed-
+range traversal and scratch list. It commutes two-address operators when the
+RHS has one use and belongs to a different live range, then omits the copy for
+the tie it broke. An allocator-generated Split is excluded: its single use can
+hide a longer-lived value, and commuting onto it can undo an earlier repair.
+The existing coloring retry rebuilds live ranges. There is no new whole-IR pass
+or analysis structure. The machine's `commutes()` hook covers Add, Mul, Xor,
+And, and Or (and commutative floating instructions).
+
+The String score is now 21: each hash body costs 9 and the allocator costs 3.
+The three x86 expectations are updated to 21. A single regression exercises
+all five integer operators in both machine operand orders, checks register
+constraints, and encodes the results. Existing testFlags2 guards against the
+Split-operand regression.
+
+## Incremental Iter setup
+
+Iter no longer resets and requeues the whole graph after parsing. `Node.init()`
+queues initialized nodes, and Calls queue themselves so Iter can link targets
+loaded after the call was parsed. Iter seeds its new global escape summary,
+then drains the worklist. Import loading still resets the newly loaded nodes
+and repairs affected neighbors.
+
+Removing the sweep also exposed an unused receiver left behind when field
+lookup switches from an instance to its class. That path now releases the keep
+and kills the receiver if unused.
+
+FldOffNode's base is data in slot 1. Slot 0 is reserved for control; putting a
+data parameter there made inliner body discovery exclude an unresolved offset.
+The regression `CallEndTest.testUnresolvedOffsetInInlineBody` checks that the
+offset belongs to the function body even before its type resolves.
+
+## Type upgrades in Opto setup
+
+ParseAll still closes the named types and freezes the public interface after
+loading all units. It no longer walks the IR. Opto installs those closed types
+and resolves forward references during its existing reset walk. Setup runs no
+peepholes against the partly reset graph: resolved FRefs propagate their input
+type in SCCP and disappear during subsequent peepholes. Construction identities
+also wait until this setup has installed complete field declarations.
+
+That walk retains Returns while call edges are rebuilt, gathers unresolved
+numeric modes, and records dependencies for cleanup after SCCP. Type upgrades
+also register cleanup dependencies, even if the eventual value type is unchanged.
+Old Iter types are snapshots for change detection, not upper bounds after
+recursive declarations have been closed. SCCP still asserts monotone falling
+types and a complete worklist within its own solve.
+
+The move exposed assumptions formerly hidden by early upgrades. Resolving an
+already known class name must retain its known fields instead of replacing them
+with an empty placeholder. A Load must not forward a stored pointer whose open
+shape is weaker than the Load's computed type. Escape must retain escapes from
+the actual private value before narrowing it to its declared field type, so
+selecting a Store out of a MemMerge does not introduce new escapes.
+
+An unknown return or memory effect carries FULL escape sets. StopCU and
+GlobalEscape preserve that bound: narrowing it to public names would omit
+private objects and callbacks that a subsequent load-forwarding rewrite can
+reveal. Both summaries recompute from their inputs without retaining previous
+escape sets. Refining a value removes possibilities; it never adds escapes
+outside the old bound. IterPeeps has no special summary handling, and its
+worklist assertion requires every node's type to lift, including queued nodes.
+`testUnknownEscapesRefineToPrivateValues` checks both escape sinks;
+`testPrivateRecursiveReturn` covers cyclic private objects and field loads.
+
+The ordinary dependency mechanism also covers signature refinement and lazy
+imports. FunNode depends on its Return when refining its signature. Calls
+depend on their target FunNode; while a target is absent, they depend on Start
+and retry when a linker mapping is installed. Loading bodies and adding call
+edges still use `Iter.repair` for graph changes, not for value refinement.
+
+SCCP's Start linking honors FULL for private targets too. Incoming arguments
+are not themselves outgoing escapes; Stores, Returns, and external-call
+arguments describe those effects. Constant singleton field slices carry the
+stored value's escapes, not unrelated bulk-memory escapes frozen at the time
+of folding. This preserves precision for local recursive callbacks.
+
+As bounds sharpen after SCCP, FunNode removes unknown callers even if concrete
+callers remain. Called initializers retain their Return through StopCU because
+CLInit has no Return edge. Removing an uncalled function queues the affected
+Stops, and compilation-unit wrappers retain distinct identities even when
+empty. These are worklist notifications and ownership rules, not extra passes.
+
+### Remaining precision issue with FULL escapes
+
+The monotone bounds expose a feedback problem in the existing grand cycle.
+In `Chapter12Test.testPrivateRecursiveReturn`, SCCP sees BOTTOM for the final
+load chain and therefore FULL escapes. These feed through Start's memory into
+the function memory results. Post-SCCP forwarding resolves the return to `_B`,
+but that memory cycle remains at MemBot and keeps private constructors callable
+by unknown callers. This is conservative, but loses the previous dead-function
+elimination. It does not justify narrowing BOTTOM to public names or adding
+the IterPeeps widening repair back.
+
+For this example, the root Return goes from `[Ctrl, MemBot, Bot]` after Opto to
+`[Ctrl, MemBot, *_B]` after TypeCheck; Stop's memory remains MemBot. The remaining
+design question is how to avoid this feedback while retaining conservative
+bounds and the existing pass budget. The current experiment leaves the printed
+IR expectations unchanged so they continue to expose this loss of precision.
+
+The source-validity audit separates these cases from Chapter10 `testIfOrNull`.
+That test originally returned the nullable pointer itself. A historical edit
+changed it to `return bar.a;` without adding TypeCheck, leaving a real null
+error hidden behind a printed-IR assertion. It now again returns `bar`, runs
+through TypeCheck, and passes with `return Phi(Region,null,Test._Bar);`.
+
+All four remaining programs pass TypeCheck. The Chapter12 recursive-object
+tests have one pending bad Load after Opto; forwarding eliminates every Load
+and resolves the result to `17`, `_B`, or `_R`, while escape memory stays MemBot.
+
+The two Chapter17b forward-type tests now pass their original IR expectations
+and explicitly run TypeCheck. Their undeclared `B` is an opaque external type,
+not an arbitrary unknown local value. EscapeNode uses the frozen public
+interface as its fallback for that type, preserving escapes already present
+in its private and public memory inputs. This applies to both the actual private
+value and the declared field bound: in `testForward1`, the private array element
+type is already `B?`. Bounding only FIDXs is insufficient; FULL aliases expose
+private class fields, which then expose their constructor FIDXs.
+
+CodeGen caches the fallback when ParseAll freezes the public interface. It
+returns FULL until Opto's existing reset walk installs the closed types into
+the IR. Only then does an open `_fref` reliably denote an external object.
+Ordinary open local structs, `TypeScalar.BOT`, and abstract pointers still
+require FULL. No new pass or IterPeeps hook is involved.
+
+### Escape-bound audit
+
+All Chapter29 `escapesFrom`, `XInt.FULL`, and `TypeMem.BOT` uses were reviewed,
+including the equivalent `escapes` helper and indirect `makePrivate` uses.
+The following remaining sites warrant work; they have not been narrowed here:
+
+* **StoreNode's three `escapesFrom(val)` paths.** The singleton constant,
+ private instance, and ordinary aliasing paths still treat an opaque external
+ value as FULL. A concrete example is:
+
+ ```simple
+ struct _A { B? !b; new _A = { B? p -> b=p; }; };
+ val wrap = { B? b -> return new _A(b); };
+ return wrap;
+ ```
+
+ This passes TypeCheck, but the surviving Stores of `b` have FULL FIDX and
+ alias sets and keep an extra `_A` allocator. Do not mechanically substitute
+ the public default: singleton slices deliberately discard unrelated incoming
+ memory escapes. A narrower opaque bound must retain private values previously
+ given to external code, without freezing unrelated transient escapes into
+ singleton constants. Ordinary aliasing Stores already retain incoming sets.
+
+* **StopCU return values, CallNode external arguments, and SummaryNode arguments.**
+ These use `escapes`, which currently duplicates the unbounded `escapesFrom`.
+ `val identity = { B? b -> return b; }; return identity;` passes TypeCheck but
+ gives StopCU MemBot. Likewise,
+ `val send = { B? b -> { B? -> int } retain = "C"; return retain(b); }; return send;`
+ gives the native CallEnd MemBot. These are concrete instances of the same
+ opaque-type precision loss. A future change can share the bounded helper,
+ while retaining incoming/returned memory, explicit private arguments, and
+ imported-summary effects. StopCU's public function seed and `escapesAliases`
+ class seed describe known declarations; GlobalEscape's `escapes` calls merely
+ union already computed TypeMem sets.
+
+* **CLInitNode's possible-initialization result.** Even a known initializer
+ yields TypeMem.BOT until its ready flag is true; only the initialized flag's
+ slice is precise. This loses both memory-content and escape precision.
+ CLInit has an analysis call edge but no Return edge supplying callee effects.
+ Improving it needs a dependency on the initializer's effects, including
+ recursive dependencies. The public interface alone is not a safe cap: that
+ initializer can publish private callbacks. Its unresolved-flag BOT fallback
+ is also conservative, but normally disappears when the receiver resolves.
+
+* **CallEndNode's unknown or unlinked target result.** Both return TypeMem.BOT.
+ The unlinked case covers invalid calls and transient missing edges; SCCP links
+ valid finite targets before computing CallEnd. Truly unknown targets can be
+ local private functions as well as external functions, so a public-only cap
+ would be wrong. Native calls already use CallNode's argument/memory effects.
+ Any refinement must distinguish target provenance and retain explicit private
+ arguments; it is not the same small fix as an opaque external object.
+
+The other broad defaults appear justified:
+
+* TypeMem's generic BOTTOM/scalar/pointer cases and TypeStruct's unknown scalar
+ fields must include private callbacks that later value refinement can reveal.
+ Private-memory declarations/Phis use `makePrivate(BOTTOM)` for the same reason.
+ MemMerge's `makePrivate(struct)` describes actual fields, not an external
+ opaque object.
+* Parser/function memory declarations, initial node types, TypeTuple.STATE,
+ and low-family error fallbacks in Store, MemMerge, BulkMemPhi, Start, and
+ TypeTuple need worst-case memory. CallNode and SummaryNode likewise cannot
+ promise bounded effects when their inputs are still arbitrary low values.
+* Iter's import reset gives newly loaded nonconstant memory FULL before repairing
+ affected neighbors. Saved types belong to another caller context; constants
+ retain their own computed types. This is a safe pessimistic seed, though it
+ can contribute to the existing memory-cycle precision problem.
+* TypeFunPtr's FULL signatures and TypeRPC.BOT describe unknown function/return
+ targets. Serialization/remapping preserves the FULL sentinel; type gathering
+ exercises it; LoopNode's `isa(TypeMem.BOT)` is a type-family check. These do
+ not independently manufacture an escape effect.
+
+Ignoring assertions, the remaining whole-node walks through TypeCheck are
+Opto's setup/reset and TypeCheck's error scan. Class-init checking separately
+scans function control flow and runs SCC over the resulting dependency graph.
+
+## Validation
+
+Chapter29's latest run tested 594 active cases with assertions: 590 passed and
+four printed-IR comparisons failed. `make -k tests` ran raw0 (400/404), raw1
+(38), standalone (13), system (29), and fuzzer (1); the aggregate group (109)
+was run separately because Make skips it after a prerequisite failure.
+The remaining failures retain extra private constructors: Chapter12 `testCoRecur`,
+`testCoRecur2`, `testCoRecur3`, and `testPrivateRecursiveReturn`. Both Chapter17b
+forward tests pass unchanged goldens. TypeTest.testOpaqueExternalEscapes checks
+both fallback sets, preservation of prior private escapes, and FULL for ordinary
+unknowns. System, spill, import, callback, and constructor tests pass. The only
+golden changed during the earlier source-validity audit belongs to the restored
+`return bar;` source; none were changed to accept extra functions.
+
+The offset regression fails with the old slot-0 layout. Constructor and
+boundary tests pass, including both missed rejections and the prior NPE.
+Printed-IR expectations omit the dead private-helper MemTop Returns; no
+rejection expectations were relaxed. The three String spill checks now expect
+21 on x86, with the same live functions and inlining decisions.
diff --git a/docs/chapter31/drawing.md b/docs/chapter31/drawing.md
new file mode 100644
index 00000000..af0a7f06
--- /dev/null
+++ b/docs/chapter31/drawing.md
@@ -0,0 +1,100 @@
+# Drawing in Simple
+
+`sys.draw` draws into an ordinary `u32[]`. It has no native dependencies and
+does not open a window. `sys.gfx` presents the resulting pixels using the
+optional [MiniFB backend](graphics.md).
+
+```simple
+val canvas = new sys.draw.Canvas(600,780);
+u32 orange = 0xe47b57;
+canvas.clear(sys.draw.WHITE);
+canvas.fillRect(20,20,180,80,orange);
+canvas.rect(20,20,180,80,3,sys.draw.BLACK);
+canvas.circle(300,200,48,8,sys.draw.BLUE);
+canvas.fillCircle(420,200,20,sys.draw.GREEN);
+canvas.line(40,320,250,440,12,orange);
+canvas.text(40,500,"YOUR TURN",4,sys.draw.BLACK);
+// After sys.gfx.open succeeds:
+sys.gfx.present(canvas.pixels);
+```
+
+## Small raster API
+
+Coordinates start at the upper left; x increases rightward and y downward.
+Drawing methods return the canvas for chaining. Everything is opaque RGB;
+there is no blending, antialiasing, transform stack, or native drawing state.
+Coordinates accept `i64` so ordinary widened arithmetic can be passed directly.
+Circle radii and circle stroke widths are `int`. Use screen-sized coordinates,
+extents, and stroke widths.
+
+| Operation | Behavior |
+| --- | --- |
+| `rgb(r,g,b)` | Clamp components to 0–255 and pack RGB. `BLACK`, `WHITE`, `RED`, `GREEN`, and `BLUE` are available too. |
+| `new Canvas(width,height)` | Allocate a zero-filled pixel array. Dimensions must be positive and their product must fit a signed 32-bit index; invalid sizes exit with status 1. Allocation can still fail for lack of memory. |
+| `clear(color)` | Fill the entire canvas. |
+| `pixel(x,y,color)` | Set one pixel; ignore points outside the canvas. |
+| `fillRect(x,y,w,h,color)` | Fill a half-open rectangle: x through x+w-1, y through y+h-1. |
+| `rect(x,y,w,h,thickness,color)` | Draw an inward outline. A sufficiently wide outline fills the rectangle. |
+| `fillCircle(cx,cy,radius,color)` | Fill a circle; radius zero draws its center pixel. |
+| `circle(cx,cy,radius,thickness,color)` | Draw an inward circular outline. |
+| `line(x0,y0,x1,y1,thickness,color)` | Draw a round-ended segment. A zero-length segment draws a round dot. Thickness is its geometric diameter, sampled at integer pixel centers. |
+| `text(x,y,string,scale,color)` | Draw a 3×5 bitmap font, with one column between characters. Upper/lowercase ASCII letters, digits, spaces, and basic punctuation are supported. Newline advances six scaled pixels. Unknown characters use `?`. |
+
+Shapes clip to the canvas. Nonpositive rectangle extents, stroke widths, or
+text scale do nothing; negative circle radii do nothing. Line and circle
+coverage scans only the clipped bounding box, which is sufficient for small
+turn-based games. This is a starting raster library rather than a fast animated
+renderer. The pixel array remains available for direct drawing and inspection.
+
+Packed colors use hexadecimal literals (`0xRRGGBB`); `rgb(r,g,b)` remains handy
+for computed components. The font masks use leading-zero octal, with five
+digits after the prefix: each digit is one three-pixel row, top to bottom.
+For example, `A` is `025755`: rows `010`, `101`, `111`, `101`, `101`.
+
+## Button widget
+
+`new sys.draw.Button(x,y,width,height,label)` creates a rectangular software
+button. It owns no window or native resources. Feed pointer coordinates and
+the held mouse-button state to `update(x,y,down)` once per event-loop iteration.
+It returns true once on release inside, provided the press began inside.
+Dragging in from outside does not activate it; releasing outside cancels.
+
+`hovered` and `pressed` expose visual state. Set `enabled=false` to cancel a
+pending press and disable activation; `label` can be changed at any time.
+`contains(x,y)` uses half-open bounds. `draw(canvas,scale,foreground,background,active)`
+paints the button and centers its 3x5 label when it fits. Colors belong to the
+caller. Keyboard shortcuts belong to the application; this small widget has
+no focus system. The [clock example](examples/Clock.smp) shows mouse activation
+and a Space shortcut in the same single-threaded loop.
+
+## Graphical tic-tac-toe
+
+From `chapter31`:
+
+```sh
+make run-graphical-tictactoe CTAGS=
+```
+
+Click an empty square or press **1–9**, numbered left-to-right, top-to-bottom.
+You are X; the CPU is O. It wins when possible, blocks your immediate win,
+then prefers the center, corners, and edges. The result stays visible with
+the winning line highlighted. Press **R** or **Space** to start a new game,
+or **Q**/**Escape** to quit. Restart is also available during a game.
+
+Held buttons and keys do not take repeated turns. Moving the pointer highlights
+empty cells. The canvas is redrawn only after a move, restart, or hover change;
+polling still processes window events and waits between polls.
+
+`make graphical-tictactoe CTAGS=` builds without opening a window or requiring
+MiniFB. Its output is `build/examples/x86_64_v2_win64/TicTacToeGraphical.exe`
+on Windows or `build/examples/x86_64_v2_SystemV/TicTacToeGraphical` on Linux.
+Backend discovery and explicit overrides follow [the pixel-window instructions](graphics.md).
+Passing any argument performs a five-poll, self-closing smoke test.
+
+The original `make tictactoe` still builds the terminal game.
+The graphical game also uses optional [background audio](audio.md): a soft
+boop for accepted moves, rising tones for a player win, and falling tones for
+a CPU win. Bad input is silent. The run target builds/configures both backends.
+`make test CLASS=DrawTest CTAGS=` checks raster pixels and complete game turns
+without a display. `make tests_nographics CTAGS=` runs the ordinary suite with
+the backend disabled.
diff --git a/docs/chapter31/escape-flow.md b/docs/chapter31/escape-flow.md
new file mode 100644
index 00000000..8ad49f15
--- /dev/null
+++ b/docs/chapter31/escape-flow.md
@@ -0,0 +1,147 @@
+# Outside-world escape flow
+
+The `escape-flow` branch developed this design in Chapter29 and backported it
+to Chapters 28a/28b. Chapter28a uses `LocalIds` and its existing initializer
+root/lowering protocol; Chapter28b adds saved summaries and cofinite identity
+remapping. Neither port imports Chapter29's deferred-construction model.
+
+## The cycle
+
+The outside world's access to local objects and functions is a value in the
+Start/Stop cycle. Public declarations are not that value. The frozen public
+interface inventory has been removed; public-method and public-alias tests
+check the converged outside escape sets directly.
+
+At Opto setup, the outer Start receives a constant foreign-identity seed:
+
+```
+foreign identities = FULL minus identities owned by this compilation
+outside identities = foreign identities union discovered local escapes
+```
+
+Known native function and storage identities are foreign too. The infinite
+tail permits future foreign functions even when no local callback has escaped.
+The finite exclusions include registered imported identities, including bodies
+represented by summaries. This scans identity tables, not the IR. Opto's
+existing reset/worklist solve discovers the local escapes.
+
+StartCU includes the shared outside state. Infinite FIDX sets must retain their
+exclusions: `Opto.linkStart` checks each local function's membership rather than
+treating every cofinite set as FULL. Serialization remaps a cofinite set by
+remapping its finite exclusions and complementing again.
+
+Before Opto, outside queries remain pessimistic. During SCCP, a consumer can
+read the foreign seed before Start has run: otherwise a temporary finite set
+can turn a generic foreign callback into an unrelated local function with the
+same arity. No previous escape result is accumulated to repair value flow.
+
+## Consumers and dependencies
+
+* Memory and callback defaults for unknown callers use Start's memory
+ projection. Parm applies its declared callback signature to the escaping
+ FIDX set. Defaults whose values do not depend on escapes remain constants.
+* Unknown/native CallEnd results use the outside state and the current call's
+ exposed arguments. Callback results are bounded by their declared signature
+ and the outside FIDX set. Explicit linked Return effects are retained.
+* CallEnd registers a direct dependency on Start. Depending only on Call is
+ insufficient: Call can stay CONTROL while the outside set changes. This
+ first implementation uses `addDepForwards`; it does not change CallEnd's
+ input layout or introduce a synthetic Fun/Return pair.
+* Infinite calls use the outside continuation. Escaped local functions remain
+ callable through Start, and their effects return through Stop. Attaching
+ every same-arity body to an infinite call is incorrect: argument/result
+ families may differ despite the equal arity.
+* Outgoing Call argument exposure is independent of CallEnd, including a call
+ that never returns. The shared fixed point also permits immediate callback
+ round trips and reentrant calls.
+* Opaque external values in Escape, Store, Return, and imported-summary paths
+ use the current outside/input-memory bound. Explicit private values and
+ already accumulated escapes remain included. Arbitrary local BOTTOM values
+ still require FULL.
+* Function-valued Loads use their memory input's FIDX bound, joined with the
+ field declaration. They do not independently manufacture an escape set.
+
+CLInit was another unconditional producer of FULL. During Opto it now depends
+on the known initializer's Return and merges those effects with incoming
+memory. It waits for live memory before publishing effects. Lowering computes
+the replacement CallEnd's linked type before exposing its projections, so a
+temporary unlinked MemBot cannot widen the already analyzed caller.
+
+## Why the recursive tests needed local precision too
+
+All four former Chapter12 printed-IR failures pass their original expectations.
+The problem was not solely an outside-call default:
+
+1. Convert widened a non-null pointer to a nullable declaration until its later
+ identity rewrite. It now preserves non-nullness in compute. Preserving the
+ entire source shape conditionally failed the existing monotonicity test;
+ shape and access still follow the declaration.
+2. A Load could wait until post-SCCP Store forwarding to recover that non-null
+ pointer. In the meantime the next dereference produced BOTTOM, which fed
+ FULL around the memory cycle and revived private constructors.
+3. Forwarded addresses, resolved field aliases, and already-bypassed CLInit
+ projections were also waiting for graph rewrites.
+
+Load's existing forwarding search now supplies pointer value facts during
+compute, without constructing or rewriting nodes. It can recognize a stored
+address through a valid pointer conversion or a proven forwarded Load, use a
+resolved named field before layout, and follow an existing CLInit bypass.
+The same dependency mechanism reruns the search when its evidence changes.
+Nullable/incomplete access checks still precede this sharpening; arbitrary
+memory Phis and unknown stores remain barriers. This adds local queries, not a
+new whole-program pass, analysis map, or second IR.
+
+## Hidden class receivers
+
+A class function's hidden `#self` defaults to its owning class singleton.
+Seeding that input with TypePtr.PTR used to give FULL escapes when forwarded
+to an unknown callback. For example:
+
+```simple
+val _hidden_noInline = { int n -> return n+2; };
+val apply_noInline = { {int->int} f,int n -> return f(n); };
+return apply_noInline;
+```
+
+`FunNode.defaultArg` now supplies the Test class singleton for this receiver,
+both during parsing and when Opto reconnects unknown callers. The callback
+parameter still reads Start dynamically. The printed-IR regression verifies
+that only `<clinit>` and `apply_noInline` escape; `_hidden_noInline` stays private.
+
+The function signature retains TypePtr.PTR in this slot. The backends use it
+to identify the hidden ABI argument, and callback annotations must not bind
+the receiver to the class where the annotation appears. Explicit calls still
+supply their actual receiver; instance methods keep their declared object
+type. This sharpens the default input, not every abstract pointer: a local
+merge of a private object and private callback can also have an abstract
+pointer type, so capping arbitrary TypePtr values at outside escapes is unsound.
+
+## Remaining design question
+
+Late imported identities also deserve further precision work: identities not
+registered when Opto starts lie in the foreign tail. Existing import tests
+exercise correctness, but this can remain conservative when bodies arrive
+later. This limitation is not repaired by resetting the whole IR.
+
+## Validation
+
+The combined `make -k tests CHAPTERS="chapter28a chapter28b chapter29" CTAGS=`
+run passes with assertions: 538 active tests in Chapter28a, 578 in Chapter28b,
+and 601 in Chapter29. Shared printer and ISA checks pass too. The log is saved
+under ignored `build/escape-backport-final.log`.
+
+`EscapeFlowTest` covers opaque wrappers with three worklist seeds, foreign
+callback parameters, private callbacks passed to and returned from native code,
+and a CallEnd dependency that responds when only Start's escape set changes.
+It also checks that public allocators escape while their private-memory helpers
+do not. Class-init tests cover unknown callback cycles; saved-library tests in
+28b/29 verify private-function exclusions survive identity remapping.
+The existing suites cover nonreturning native calls, class-init cycles,
+constructor rejection, serialization, imported callbacks, and three backends.
+The four recursive-class examples also pass a separate 16-case worklist-seed
+check. Backport IR expectations now retain public allocator Returns instead of
+private-memory helper Returns. The String allocation/encoding/export fixtures
+in 28a/28b/29 now declare `_hashCodeString` before the public `hashCode` wrapper,
+so the implicit return exposes the wrapper. Their measured spill expectations
+are 12/8/6 (x86/RISC-V/ARM); native x86 export expects 12. The reordered fixture
+also measures 8 on RISC-V before the backport, versus the old order's 6.
diff --git a/docs/chapter31/examples/A_helloWorld.smp b/docs/chapter31/examples/A_helloWorld.smp
new file mode 100644
index 00000000..4ed6abdf
--- /dev/null
+++ b/docs/chapter31/examples/A_helloWorld.smp
@@ -0,0 +1,2 @@
+sys.io.p("Hello, World!\n");
+return 0;
diff --git a/docs/chapter31/examples/BubbleSort.smp b/docs/chapter31/examples/BubbleSort.smp
new file mode 100644
index 00000000..6bdb13a9
--- /dev/null
+++ b/docs/chapter31/examples/BubbleSort.smp
@@ -0,0 +1,38 @@
+
+// An implementation of
+// https://sampleprograms.io/projects/bubble-sort/
+//
+// Bubble sort, with reading (parsing) and writing (formatting) the data.
+
+val sort = { !sys.aryi64 nums ->
+ sys.aryi64?! more = nums;
+ while( more ) {
+ more = null;
+ for( u32 i = 0; i < nums.len-1; i++ )
+ if( nums.buf[i] > nums.buf[i+1] ) {
+ u32 next = i+1;
+ more = nums.swap(i,next);
+ }
+ }
+};
+
+// Read one process argument of the form "[ 4, 3, 2, 1]" and print it sorted.
+// args[0] is the executable name, as with native C argc/argv.
+val main = { u8[~][] args ->
+ val usage = { ->
+ sys.io.p("Usage: please provide a list of at least two integers to sort in the format \"[1, 2, 3, 4, 5]\"\n");
+ return 0;
+ };
+ if( args# != 2 ) return usage();
+
+ var nums = sys.aryi64.parseAryI64(args[1]);
+ if( !nums ) return usage();
+ if( nums.len < 2 ) return usage();
+
+ // Bubble sort array of integers
+ sort(nums);
+
+ // Print the same bracketed array-list format accepted as input.
+ nums.str().add('\n').write(1);
+ return 0;
+};
diff --git a/docs/chapter31/examples/Capitalize.smp b/docs/chapter31/examples/Capitalize.smp
new file mode 100644
index 00000000..00fdd481
--- /dev/null
+++ b/docs/chapter31/examples/Capitalize.smp
@@ -0,0 +1,23 @@
+
+// An implementation of
+// https://sampleprograms.io/projects/capitalize/
+
+val main = { u8[~][] args ->
+ if( args# > 2 ) {
+ sys.io.p("Use quotes around multiple strings.\n");
+ return 0;
+ }
+ if( args# != 2 || args[1]# == 0 ) {
+ sys.io.p("Usage: please provide a string\n");
+ return 0;
+ }
+
+ u8[~] input = args[1];
+ var output = new u8[input# + 1];
+ u32 len = input#;
+ sys.ary.copy(output,0,input,0,len);
+ output[0] = sys.char.toupper(input[0]);
+ output[input#] = '\n';
+ sys.io.p(output);
+ return 0;
+};
diff --git a/docs/chapter31/examples/Clock.smp b/docs/chapter31/examples/Clock.smp
new file mode 100644
index 00000000..900126e1
--- /dev/null
+++ b/docs/chapter31/examples/Clock.smp
@@ -0,0 +1,105 @@
+// Local clocks; click the button or press Space to switch. Q/Escape exits.
+// Any argument runs ten frames, exercising both faces for smoke tests.
+u32 GREEN=0x62ff70, DIM=0x123d20, BACKGROUND=0x020b05;
+val main = { u8[~][] args ->
+ val screen=new sys.draw.Canvas(720,540);
+ if( sys.gfx.open("Simple digital and analog clocks",screen.width,screen.height)!=sys.gfx.OK ) return 1;
+ val button=new sys.draw.Button(220,454,280,52,"ANALOG CLOCK");
+ val timers=new sys.time.Timers();
+ i64 frame=timers.every(33);
+ if( frame<0 ) { sys.gfx.close(); return 1; }
+ bool analog=false, held=false;
+ int frames=0, status=sys.gfx.OK;
+ while( status==sys.gfx.OK ) {
+ bool space=sys.gfx.key(' ');
+ bool down=sys.gfx.button(sys.gfx.LEFT);
+ bool click=button.update(sys.gfx.mouseX(),sys.gfx.mouseY(),down);
+ if( click || (space && !held) ) analog=!analog;
+ held=space;
+ button.label=analog ? "DIGITAL CLOCK" : "ANALOG CLOCK";
+ if( sys.gfx.key('Q') ) break;
+ if( timers.poll(sys.time.monotonic())==frame ) {
+ _draw(screen,sys.time.now(),analog);
+ button.draw(screen,3,GREEN,BACKGROUND,DIM);
+ status=sys.gfx.present(screen.pixels);
+ if( args#>1 ) {
+ frames++;
+ if( frames==5 ) analog=true;
+ if( frames==10 ) break;
+ }
+ }
+ if( status==sys.gfx.OK ) status=sys.gfx.wait(sys.time.remaining(timers.nextDeadline(),sys.time.monotonic()));
+ }
+ sys.gfx.close();
+ return status==sys.gfx.ERROR ? 1 : 0;
+};
+val _draw = { !sys.draw.Canvas screen, i64 wall, i64 analog ->
+ val d=sys.time.local(wall);
+ screen.clear(BACKGROUND);
+ screen.text(266,24,"SIMPLE CLOCK",4,GREEN);
+ if( analog ) _analog_noInline(screen,d.hour,d.minute,d.second,d.millisecond);
+ else _digital_noInline(screen,d.hour,d.minute,d.second);
+ screen.text(298,412,"%(sys.time.formatLocal(wall,sys.time.DATE))",4,GREEN);
+ screen.text(249,520,"SPACE SWITCH Q OR ESC QUIT",2,GREEN);
+};
+// a..g bits: top, upper-right, lower-right, bottom, lower-left, upper-left,
+// middle. Faint unlit bars retain the old LED figure-eight silhouette.
+val _digit_noInline = { !sys.draw.Canvas c, int x, int y, i64 digit ->
+ int bits=digit==0 ? 0x3f : digit==1 ? 0x06 : digit==2 ? 0x5b :
+ digit==3 ? 0x4f : digit==4 ? 0x66 : digit==5 ? 0x6d :
+ digit==6 ? 0x7d : digit==7 ? 0x07 : digit==8 ? 0x7f : digit==9 ? 0x6f : 0;
+ for( int i=0; i<7; i++ ) {
+ u32 color=(bits&(1<<i))!=0 ? GREEN : DIM;
+ if( i==0 || i==3 || i==6 ) {
+ int dy=i==0 ? 0 : i==3 ? 144 : 72;
+ c.line(x+14,y+dy,x+58,y+dy,10,color);
+ } else {
+ bool right=i==1 || i==2;
+ int dx=right ? 68 : 4, dy=i==1 || i==5 ? 12 : 84;
+ c.line(x+dx,y+dy,x+dx,y+dy+48,10,color);
+ }
+ }
+};
+val _digital_noInline = { !sys.draw.Canvas c, int hour, int minute, int second ->
+ _digit_noInline(c,68,166,hour/10); _digit_noInline(c,154,166,hour-hour/10*10);
+ _digit_noInline(c,284,166,minute/10); _digit_noInline(c,370,166,minute-minute/10*10);
+ _digit_noInline(c,500,166,second/10); _digit_noInline(c,586,166,second-second/10*10);
+ for( int i=0; i<2; i++ ) {
+ c.fillCircle(254+i*216,214,6,GREEN);
+ c.fillCircle(254+i*216,262,6,GREEN);
+ }
+ c.text(272,352,"LOCAL TIME 24H",3,GREEN);
+};
+// Rational sine approximation, accurate enough for pixel clock geometry.
+// Arguments are nonnegative turns; no additional native math dependency.
+val _sin_noInline = { f64 turn ->
+ i64 whole=turn; turn-=whole;
+ bool negative=turn>0.5;
+ f64 x=(negative ? turn-0.5 : turn)*6.283185307179586;
+ f64 product=x*(3.141592653589793-x);
+ f64 numerator=negative ? -16.0 : 16.0;
+ return numerator*product/(49.34802200544679-4*product);
+};
+val _hand_noInline = { !sys.draw.Canvas c, f64 turn, int length, int thickness, u32 color ->
+ int x=360+_sin_noInline(turn)*length, y=232-_sin_noInline(turn+0.25)*length;
+ c.line(360,232,x,y,thickness,color);
+};
+val _analog_noInline = { !sys.draw.Canvas c, int hour, int minute, int second, int millisecond ->
+ c.circle(360,232,154,3,GREEN);
+ for( int i=0; i<60; i++ ) {
+ f64 turn=i/60.0;
+ bool major=i-i/5*5==0;
+ int inner=major ? 133 : 143;
+ int x0=360+_sin_noInline(turn)*inner, y0=232-_sin_noInline(turn+0.25)*inner;
+ int x1=360+_sin_noInline(turn)*149, y1=232-_sin_noInline(turn+0.25)*149;
+ c.line(x0,y0,x1,y1,major ? 3 : 1,GREEN);
+ }
+ c.text(349,100,"12",2,GREEN); c.text(484,227,"3",2,GREEN);
+ c.text(357,354,"6",2,GREEN); c.text(230,227,"9",2,GREEN);
+ f64 seconds=second+millisecond/1000.0;
+ f64 minutes=minute+seconds/60.0;
+ _hand_noInline(c,(hour-hour/12*12+minutes/60.0)/12.0,78,8,GREEN);
+ _hand_noInline(c,minutes/60.0,112,5,GREEN);
+ _hand_noInline(c,seconds/60.0,128,2,0xc3ffcb);
+ c.fillCircle(360,232,6,GREEN);
+};
diff --git a/docs/chapter31/examples/Dijkstra.smp b/docs/chapter31/examples/Dijkstra.smp
new file mode 100644
index 00000000..768bdd08
--- /dev/null
+++ b/docs/chapter31/examples/Dijkstra.smp
@@ -0,0 +1,74 @@
+
+// An implementation of
+// https://sampleprograms.io/projects/dijkstra/
+
+val shortestPath = { !sys.aryi64 matrix, int nodes, int src, int dst ->
+ i64 infinity = (1 << 63)-1;
+ var dist = new i64[nodes];
+ var visited = new sys.adt.bitset();
+ // All nodes are infinitely far from each other
+ for( int i=0; i<nodes; i++ ) dist[i] = infinity;
+ // Starting point is zero far from starting point
+ dist[src] = 0;
+
+ i64 result = -1;
+ int count = 0;
+ while( count < nodes && result < 0 ) {
+ int nearest = -1;
+ i64 best = infinity;
+ for( int node=0; node<nodes; node++ )
+ if( !visited.get(node) && dist[node] < best ) {
+ nearest = node;
+ best = dist[node];
+ }
+ if( nearest < 0 ) count = nodes;
+ else {
+ if( nearest == dst ) return best;
+ visited.set(nearest);
+ for( int node=0; node<nodes; node++ ) {
+ u32 index=nearest*nodes + node;
+ i64 weight = matrix.get(index);
+ if( weight > 0 && !visited.get(node) && best+weight < dist[node] )
+ dist[node] = best+weight;
+ }
+ }
+ count++;
+ }
+ return result;
+};
+
+val usage = { ->
+ sys.io.p("Usage: please provide three inputs: a serialized matrix, a source node and a destination node\n");
+ return 0;
+};
+
+val main = { u8[~][] args ->
+ // Exactly 3 args
+ if( args# != 4 ) return usage();
+ // First is a serialized square matrix of positive numbers
+ var matrix = sys.aryi64.parseAryI64(args[1]);
+ if( !matrix ) return usage();
+ if( !matrix.len ) return usage();
+ // All numbers are positive
+ for( u32 i=0; i<matrix.len; i++ )
+ if( matrix.get(i) < 0 ) return usage();
+ // Matrix is square
+ int nodes = 0;
+ while( nodes*nodes < matrix.len ) nodes++;
+ if( nodes*nodes != matrix.len )
+ return usage(); // Not a square matrix
+
+ // src,dst are values within the matrix
+ i64 src = sys.aryu8.atoi(args[2]);
+ i64 dst = sys.aryu8.atoi(args[3]);
+ if( src < 0 || src >= nodes || dst < 0 || dst >= nodes )
+ return usage(); // Source and destination land within the graph
+
+ int source=src, destination=dst;
+ i64 cost = shortestPath(matrix,nodes,source,destination);
+ if( cost >= 0 ) {
+ new sys.aryu8().num(cost).add('\n').write(1);
+ return 0;
+ }
+ return usage();
+};
diff --git a/docs/chapter31/examples/FileIO.smp b/docs/chapter31/examples/FileIO.smp
new file mode 100644
index 00000000..59b3159e
--- /dev/null
+++ b/docs/chapter31/examples/FileIO.smp
@@ -0,0 +1,52 @@
+
+// An implementation of
+// https://sampleprograms.io/projects/file-input-output/
+
+val FILE = "output.txt";
+
+val main = { u8[~][] args ->
+ var str = new sys.aryu8();
+ str.addAry("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n");
+ str.addAry("Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n");
+ str.addAry("Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n");
+ str.addAry("Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n");
+
+ // Create
+ i32 fd = sys.io.create(FILE);
+ if( fd == -1 ) return _error();
+ // Write
+ u32 len = str.len;
+ int wrote = sys.io.writeOff(fd,str.buf,0,len);
+ if( wrote == -1 ) return _error();
+ if( wrote != str.len ) return _error2(-2);
+ // Close
+ if( sys.io.close(fd) != 0 ) return _error();
+
+ // Open
+ i32 fd2 = sys.io.open(FILE);
+ if( fd2 == -1 ) return _error();
+ // Read
+ var got = sys.io.read(fd2);
+ if( !got ) return _error();
+ if( got# != str.len ) return _error2(-3);
+ // Close
+ if( sys.io.close(fd2) != 0 ) return _error();
+
+ // Check contents are equal
+ if( sys.ary.cmp(got,0,str.buf,0,len) )
+ return _error2(-4);
+
+ // Remove
+ if( sys.io.remove(FILE) != 0 ) return _error();
+
+ sys.io.p("File I/O succeeded\n");
+ return 0;
+};
+
+val _error2 = { i32 errno ->
+ sys.io.remove(FILE);
+ new sys.aryu8().addAry("Failed with error code ").num(errno).add('\n').write(sys.libc.STDERR);
+ sys.libc._exit(errno);
+};
+
+val _error = { -> _error2(sys.libc.errno()); };
diff --git a/docs/chapter31/examples/Graphics.smp b/docs/chapter31/examples/Graphics.smp
new file mode 100644
index 00000000..7ba5c6e7
--- /dev/null
+++ b/docs/chapter31/examples/Graphics.smp
@@ -0,0 +1,17 @@
+// Optional graphics smoke test: pixels are drawn entirely in Simple.
+// Pass any argument for a short, self-closing test; otherwise close or press Esc.
+val main = { u8[~][] args ->
+ if( sys.gfx.open("Simple pixels",320,240) != sys.gfx.OK ) return 1;
+ val pixels = new u32[320*240];
+ for( int y=0; y<240; y++ )
+ for( int x=0; x<320; x++ )
+ pixels[y*320+x] = (x*255/319 << 16) | (y*255/239 << 8) | 64;
+ int result = sys.gfx.present(pixels);
+ int frames = 0;
+ while( result == sys.gfx.OK ) {
+ result = sys.gfx.poll(true);
+ if( args# > 1 && ++frames == 5 ) break;
+ }
+ sys.gfx.close();
+ return result == sys.gfx.ERROR ? 1 : 0;
+};
diff --git a/docs/chapter31/examples/README.md b/docs/chapter31/examples/README.md
new file mode 100644
index 00000000..46965ac0
--- /dev/null
+++ b/docs/chapter31/examples/README.md
@@ -0,0 +1,84 @@
+# Runnable examples
+
+These programs live beside the documentation so readers can find and run them.
+Their non-graphical native regression tests run as part of `make tests`.
+The optional [pixel-window example](Graphics.smp) uses MiniFB; see
+[graphics setup](../graphics.md). `make run-graphics` opens it explicitly.
+The [graphical tic-tac-toe](TicTacToeGraphical.smp) uses the Simple
+[drawing library](../drawing.md). Run it with
+`make run-graphical-tictactoe CTAGS=`; click a cell or press 1–9, R/Space to
+replay, and Q/Escape to quit. The terminal game below remains available.
+Optional [background audio](../audio.md) supplies soft move and win/loss cues.
+
+The [digital and analog clock](Clock.smp) demonstrates the [time library](../time.md),
+green seven-segment digits, smooth clock hands, and a reusable software button.
+Run `make run-clock CTAGS=`; click the button or press Space to switch faces,
+and Q/Escape to exit. Both faces show system-local time.
+`make clock-example CTAGS=` builds it without starting a window.
+
+From `chapter31`, build the tic-tac-toe executable (without starting it):
+
+```sh
+make lib tictactoe CTAGS=
+```
+
+Run the Windows build from PowerShell:
+
+```powershell
+.\build\examples\x86_64_v2_win64\TicTacToe.exe
+```
+
+Or from Cygwin:
+
+```sh
+./build/examples/x86_64_v2_win64/TicTacToe.exe
+```
+
+The Windows executable uses Cygwin's runtime; its `bin` directory must be on
+`PATH` (as it is when building with Cygwin GCC).
+
+For a native Linux build, run these commands from this same `chapter31`
+directory in WSL. Separate Java and native output directories let the Windows
+and Linux builds coexist:
+
+```sh
+make lib tictactoe CLZDIR=build/classes-linux CTAGS=
+./build/examples/x86_64_v2_SystemV/TicTacToe
+```
+
+From PowerShell, prefix either command with `wsl -d Ubuntu-24.04 --`.
+On a standalone Linux checkout, `CLZDIR` can be omitted.
+The compiler's `--link` option produces an executable without running it;
+`--norun` continues to produce object files only.
+
+`TicTacToe.smp` currently prints the welcome, square numbering, and empty board,
+then waits for a move. Press **1–9** without Enter to place X, or
+**q**, Escape, Ctrl-C, or Ctrl-D to quit. Enter is silently ignored; other
+invalid keys and occupied squares prompt again without taking a turn. The board
+is printed after each player and CPU move; only accepted move digits are echoed.
+Play continues until someone wins or the board is
+full. The CPU takes an immediate win, blocks an immediate loss, then prefers
+the center, corners, and edges.
+
+Other examples are Hello World (`A_helloWorld.smp`), `BubbleSort.smp`,
+`Capitalize.smp`, `Dijkstra.smp`, and `FileIO.smp`. The latter creates, checks,
+and removes `output.txt` in the working directory.
+
+## Single-keystroke input
+
+`sys.io.getch()` blocks for one byte without terminal editing or echo. It returns
+0–255 for a byte, or -1 for EOF or an error. On -1, `sys.libc.errno()` is
+`sys.libc.EOF` (-1) for end-of-input, otherwise the OS error code.
+Terminal Ctrl-C and Ctrl-D are bytes 3
+and 4, so the application decides how to handle them. Unicode and function keys
+may produce multiple bytes; this is an ASCII-oriented byte API, not a key-event
+decoder.
+
+The small C runtime adapter uses [POSIX terminal modes](https://man7.org/linux/man-pages/man3/termios.3.html),
+including Cygwin on Windows. It restores the saved mode before returning,
+including after an interrupted read. Files and pipes use an ordinary one-byte
+read. `make test CLASS=TerminalInputTest` runs the Java regression tests for
+redirected input, errors, prompt output, Enter, and Escape. Check terminal
+editing, echo, and mode restoration by running the executable in a terminal.
+`make test CLASS=TicTacToeTest` checks complete games, all eight winning lines,
+draws, occupied squares, and CPU blocking.
diff --git a/docs/chapter31/examples/TicTacToe.smp b/docs/chapter31/examples/TicTacToe.smp
new file mode 100644
index 00000000..a710e440
--- /dev/null
+++ b/docs/chapter31/examples/TicTacToe.smp
@@ -0,0 +1,133 @@
+// Player is X, CPU is O. Squares are numbered left-to-right, top-to-bottom.
+val main = { u8[~][] args ->
+ sys.io.p("Tic-tac-toe!\nYou are X; the CPU is O. You go first.\n");
+ sys.io.p("Press a square's number (no Enter needed), or q/Esc to quit.\n");
+ sys.io.p("Squares:\n 1 | 2 | 3 \n---+---+---\n 4 | 5 | 6 \n---+---+---\n 7 | 8 | 9 \n\n");
+ val board = new u8[9];
+ for( int i=0; i<board#; i++ ) board[i]=' ';
+ // Draw empty board
+ _draw(board);
+
+ while( true ) {
+ sys.io.p("Your move (1-9): ");
+ // Valid user move 1-9, or 0 for quitting
+ int square = _validMove(board) - 1;
+ if( square < 0 ) return 0;
+ // Set user choice; print board
+ board[square] = 'X';
+ sys.io.p("\n");
+ _draw(board);
+ // A winner?
+ if( _finished(board) ) return 0;
+ // Compute CPU move
+ int reply = _cpu(board);
+ // Set CPU choice; print board
+ board[reply]='O';
+ sys.io.p("CPU plays %(reply+1).\n");
+ _draw(board);
+ // A winner?
+ if( _finished(board) ) return 0;
+ }
+};
+
+// Return with a valid user move 1-9, or 0 to exit
+val _validMove = { u8[~] board ->
+ while( true ) {
+ int key = sys.io.getch();
+ if( key == -1 && sys.libc.errno() != sys.libc.EOF ) {
+ sys.io.err("\nUnable to read the keyboard.\n");
+ return 0;
+ }
+ // Various quitting conditions
+ if( key == -1 || key == 'q' || key == 'Q' || key == 27 || key == 3 || key == 4 ) {
+ sys.io.p("\nGoodbye!\n");
+ return 0;
+ }
+ // Ignore CR/LF
+ if( key == '\r' || key == '\n' ) continue;
+
+ // Valid choice
+ if( key < '1' || key > '9' ) {
+ sys.io.p("\nChoose a square from 1 to 9: ");
+ continue;
+ }
+ if( board[key-'1'] != ' ' ) {
+ sys.io.p("\nThat square is occupied. Your move (1-9): ");
+ continue;
+ }
+ sys.io.p("%c(key)");
+ return key-'0';
+ }
+};
+
+// Return the winning mark, or zero.
+val _winner = { u8[~] board ->
+ for( int i=0; i<3; i++ ) {
+ int row = i*3;
+ if( board[row]!=' ' && board[row]==board[row+1] && board[row]==board[row+2] )
+ return board[row];
+ if( board[i]!=' ' && board[i]==board[i+3] && board[i]==board[i+6] )
+ return board[i];
+ }
+ if( board[4]!=' ' ) {
+ if( board[0]==board[4] && board[4]==board[8] ) return board[4];
+ if( board[2]==board[4] && board[4]==board[6] ) return board[4];
+ }
+ return 0;
+};
+
+val _finished = { u8[~] board ->
+ int winner = _winner(board);
+ if( winner=='X' ) { sys.io.p("You win!\n"); return true; }
+ if( winner=='O' ) { sys.io.p("CPU wins!\n"); return true; }
+ for( int i=0; i<board#; i++ )
+ if( board[i]==' ' ) return false;
+ sys.io.p("It's a draw!\n");
+ return true;
+};
+
+// Try each empty square, restoring it before returning.
+val _threat = { u8[] board, u8 mark ->
+ for( int i=0; i<board#; i++ ) {
+ if( board[i]==' ' ) {
+ board[i]=mark;
+ bool wins = _winner(board)==mark;
+ board[i]=' '; // Restore board
+ if( wins ) return i;
+ }
+ }
+ return -1;
+};
+
+// Win if possible, otherwise block, then prefer center, corners, and edges.
+// Called only after _finished has ruled out a full board.
+val _cpu = { u8[] board ->
+ // If O can win, do so
+ int win = _threat(board,'O');
+ if( win>=0 ) return win;
+ // If X will win next move, block
+ int block = _threat(board,'X');
+ if( block>=0 ) return block;
+ // If center is open, take it
+ if( board[4]==' ' ) return 4;
+ // If a corner is open, take it
+ for( int i=0; i<board#; i+=2 )
+ if( board[i]==' ' ) return i;
+ // Take whatever is available
+ for( int i=0; i<board#; i++ )
+ if( board[i]==' ' ) return i;
+ return -1;
+};
+
+val _draw = { u8[~] board ->
+ val out = new sys.aryu8();
+ for( int row=0; row<3; row++ ) {
+ for( int col=0; col<3; col++ ) {
+ out.add(' ').add(board[row*3+col]).add(' ');
+ if( col<2 ) out.add('|');
+ }
+ out.add('\n');
+ if( row<2 ) out.addAry("---+---+---\n");
+ }
+ out.write(sys.libc.STDOUT);
+};
diff --git a/docs/chapter31/examples/TicTacToeGraphical.smp b/docs/chapter31/examples/TicTacToeGraphical.smp
new file mode 100644
index 00000000..fddcf254
--- /dev/null
+++ b/docs/chapter31/examples/TicTacToeGraphical.smp
@@ -0,0 +1,167 @@
+// Player is X, CPU is O. Squares are numbered left-to-right, top-to-bottom.
+// Graphical tic-tac-toe; the same strategy as TicTacToe.smp.
+// Click an empty square or press 1-9. R/Space replays; Q/Escape quits.
+// Pass any argument for a five-poll window smoke test.
+val main = { u8[~][] args ->
+ val screen = new sys.draw.Canvas(600,780);
+ val board = new u8[9];
+ _reset(board);
+ if( sys.gfx.open("Simple tic-tac-toe",screen.width,screen.height) != sys.gfx.OK ) return 1;
+ sys.audio.open(); // Audio is optional; failure leaves the game silent.
+ _draw(screen,board,-1);
+ int result = sys.gfx.present(screen.pixels);
+ bool mouseHeld = false, keyHeld = false, replayHeld = false;
+ int hover = -1, frames = 0;
+ while( result==sys.gfx.OK ) {
+ result = sys.gfx.wait(17);
+ if( result!=sys.gfx.OK ) break;
+ if( sys.gfx.key('Q') ) break;
+ bool down = sys.gfx.button(sys.gfx.LEFT);
+ bool replay = sys.gfx.key('R') || sys.gfx.key(' ');
+ int square = _square(sys.gfx.mouseX(),sys.gfx.mouseY());
+ bool dirty = square!=hover;
+ hover = square;
+ if( replay && !replayHeld ) { sys.audio.stop(); _reset(board); dirty=true; }
+ bool digit = false;
+ int choice = down && !mouseHeld ? square : -1;
+ for( int i=0; i<9; i++ ) {
+ int key = '1'+i;
+ if( sys.gfx.key(key) ) {
+ digit = true;
+ if( !keyHeld ) choice=i;
+ }
+ }
+ if( !(replay && !replayHeld) && _move(board,choice) ) {
+ sys.audio.play(_sound(board));
+ dirty=true;
+ }
+ mouseHeld = down;
+ keyHeld = digit;
+ replayHeld = replay;
+ if( dirty ) {
+ _draw(screen,board,hover);
+ result = sys.gfx.present(screen.pixels);
+ }
+ if( args#>1 && ++frames==5 ) break;
+ }
+ sys.audio.close();
+ sys.gfx.close();
+ return result==sys.gfx.ERROR ? 1 : 0;
+};
+
+// Called only for an accepted move; invalid input never requests a sound.
+val _sound = { u8[~] board ->
+ int winner = _winner(board);
+ return winner=='X' ? sys.audio.WIN : winner=='O' ? sys.audio.LOSE : sys.audio.BOOP;
+};
+
+val _reset = { u8[] board ->
+ for( int i=0; i<9; i++ ) board[i]=' ';
+};
+val _square = { int x, int y ->
+ if( x<48 || x>=552 || y<168 || y>=672 ) return -1;
+ return (y-168)/168*3+(x-48)/168;
+};
+
+// One player turn followed by one CPU turn. Invalid/finished moves do nothing.
+val _move = { u8[] board, int square ->
+ if( square<0 || square>=9 || board[square]!=' ' || _finished(board) ) return false;
+ board[square] = 'X';
+ if( !_finished(board) ) {
+ int reply = _cpu(board);
+ if( reply>=0 ) board[reply]='O';
+ }
+ return true;
+};
+
+val _finished = { u8[~] board ->
+ if( _winner(board) ) return true;
+ for( int i=0; i<9; i++ ) if( board[i]==' ' ) return false;
+ return true;
+};
+
+val _winningLine = { u8[~] board ->
+ for( int i=0; i<3; i++ ) {
+ int row = i*3;
+ if( board[row]!=' ' && board[row]==board[row+1] && board[row]==board[row+2] ) return i;
+ if( board[i]!=' ' && board[i]==board[i+3] && board[i]==board[i+6] ) return 3+i;
+ }
+ if( board[4]!=' ' ) {
+ if( board[0]==board[4] && board[4]==board[8] ) return 6;
+ if( board[2]==board[4] && board[4]==board[6] ) return 7;
+ }
+ return -1;
+};
+// Return the winning mark, or zero.
+val _winner = { u8[~] board ->
+ int line = _winningLine(board);
+ return line<0 ? 0 : line<3 ? board[line*3] : line<6 ? board[line-3] : board[4];
+};
+
+// Try each empty square, restoring it before returning.
+val _threat = { u8[] board, u8 mark ->
+ for( int i=0; i<9; i++ )
+ if( board[i]==' ' ) {
+ board[i] = mark;
+ bool wins = _winner(board)==mark;
+ board[i] = ' '; // Restore board
+ if( wins ) return i;
+ }
+ return -1;
+};
+// Win if possible, otherwise block, then prefer center, corners, and edges.
+// Called only after _finished has ruled out a full board.
+val _cpu = { u8[] board ->
+ // If O can win, do so
+ int win = _threat(board,'O');
+ if( win>=0 ) return win;
+ // If X will win next move, block
+ int block = _threat(board,'X');
+ if( block>=0 ) return block;
+ // If center is open, take it
+ if( board[4]==' ' ) return 4;
+ // If a corner is open, take it
+ for( int i=0; i<9; i+=2 ) if( board[i]==' ' ) return i;
+ // Take whatever is available
+ for( int i=0; i<9; i++ ) if( board[i]==' ' ) return i;
+ return -1;
+};
+
+val _draw = { !sys.draw.Canvas screen, u8[~] board, int hover ->
+ u32 background = 0x142e36, paper = 0xf2eddf, ink = 0x35535a;
+ u32 cross = 0xe47b57, ring = 0x248b89, accent = 0xf0c778;
+ int winner = _winner(board);
+ bool finished = _finished(board);
+ screen.clear(background);
+ screen.text(48,34,"TIC TAC TOE",6,paper);
+ screen.fillRect(48,90,504,3,ink);
+ screen.text(48,116,finished ? (winner=='X' ? "YOU WIN!" : winner=='O' ? "CPU WINS" : "DRAW") : "YOUR TURN",4,accent);
+ screen.line(428,112,450,134,5,cross).line(450,112,428,134,5,cross);
+ screen.circle(496,123,13,5,0x70c9bf);
+ screen.text(428,145,"YOU",2,0xa2b9b7);
+ screen.text(484,145,"CPU",2,0xa2b9b7);
+ screen.fillRect(48,168,504,504,paper);
+ if( !finished && hover>=0 && hover<9 && board[hover]==' ' )
+ screen.fillRect(48+(hover-hover/3*3)*168,168+hover/3*168,168,168,0xd9e8de);
+ for( int i=1; i<3; i++ ) {
+ screen.fillRect(48+i*168-3,168,6,504,ink);
+ screen.fillRect(48,168+i*168-3,504,6,ink);
+ }
+ for( int i=0; i<9; i++ ) {
+ int cx = 132+(i-i/3*3)*168, cy = 252+i/3*168;
+ if( board[i]=='X' )
+ screen.line(cx-38,cy-38,cx+38,cy+38,12,cross).line(cx+38,cy-38,cx-38,cy+38,12,cross);
+ else if( board[i]=='O' ) screen.circle(cx,cy,48,11,ring);
+ else screen.text(cx-6,cy-10,i==0 ? "1" : i==1 ? "2" : i==2 ? "3" :
+ i==3 ? "4" : i==4 ? "5" : i==5 ? "6" : i==6 ? "7" : i==7 ? "8" : "9",4,0x95a59e);
+ }
+ int line = _winningLine(board);
+ if( line>=0 ) {
+ int first = line<3 ? line*3 : line<6 ? line-3 : line==6 ? 0 : 2;
+ int last = line<3 ? first+2 : line<6 ? first+6 : line==6 ? 8 : 6;
+ screen.line(132+(first-first/3*3)*168,252+first/3*168,132+(last-last/3*3)*168,252+last/3*168,5,0xeab64e);
+ }
+ screen.text(48,704,"CLICK A SQUARE OR PRESS 1-9",3,paper);
+ screen.text(48,738,"R OR SPACE REPLAY Q OR ESC QUIT",3,0xa2b9b7);
+ screen.rect(48,168,504,504,3,ink);
+};
diff --git a/docs/chapter31/graphics.md b/docs/chapter31/graphics.md
new file mode 100644
index 00000000..4977b490
--- /dev/null
+++ b/docs/chapter31/graphics.md
@@ -0,0 +1,132 @@
+# Optional graphics
+
+Simple owns the pixel array and drawing code. [MiniFB](https://github.com/emoon/minifb)
+provides a window, input, and pixel presentation. The first interface is
+`sys.gfx`: one fixed-size window, operated on the main thread. The
+[Graphics example](examples/Graphics.smp) draws a color gradient in Simple.
+The [drawing library and graphical tic-tac-toe](drawing.md) build on this pixel
+interface. `make run-graphical-tictactoe CTAGS=` builds the optional backend and
+opens the game; `make graphical-tictactoe CTAGS=` only builds its executable.
+The game run target also builds optional [background audio](audio.md).
+
+## Default: no graphics dependency
+
+From `chapter31`, the usual `make release`, `make tests`, and `make tictactoe`
+need no MiniFB, graphics headers, OpenGL, CMake, or running display. Nothing
+downloads dependencies unless you explicitly request `make libs` or `make init`
+(`make lib` remains a setup alias). Build and run targets never use the network.
+
+`make tests_nographics CTAGS=` runs the entire ordinary suite with
+`SIMPLE_MINIFB=off`. This includes the missing-backend tests, which never open
+windows. It excludes the interactive `run-graphics` target.
+
+The runtime contains a small loader using the OS dynamic-library facilities.
+It does nothing until `sys.gfx.open`. Without an available backend, `open`
+prints a diagnostic to stderr and returns `sys.gfx.ERROR` (-1). Programs can
+return a nonzero status or fall back to text; the library does not terminate
+the process. Non-graphics programs ignore even a broken graphics configuration.
+
+## Build the optional backend
+
+From `chapter31`:
+
+```sh
+make libs CTAGS= # Once while online; fills the persistent lib/ cache.
+make graphics graphics-example CTAGS=
+make run-graphics CTAGS=
+```
+
+`libs` downloads the revision pinned in [graphics.mk](../graphics.mk) into
+ignored `lib/minifb-<revision>.tar.gz` and extracts it under `lib/`.
+`graphics` builds only its C framebuffer backend from these local sources. Windows
+uses the MinGW-w64 GCC compiler (`x86_64-w64-mingw32-gcc`) and GDI; the rest of
+Simple still uses the existing Cygwin GCC. Linux uses GCC and X11 (`libx11-dev`
+on Ubuntu), including XWayland on a Wayland desktop. OpenGL, the C++ callback
+wrappers, and upstream example programs are omitted. Native Wayland and macOS
+builds are not provided here yet.
+
+Outputs:
+
+| Platform | Backend | Example |
+| --- | --- | --- |
+| Windows | `build/release/x86_64_v2_win64/simple_minifb.dll` | `build/examples/x86_64_v2_win64/Graphics.exe` |
+| Linux | `build/release/x86_64_v2_SystemV/libsimple_minifb.so` | `build/examples/x86_64_v2_SystemV/Graphics` |
+
+`MiniFB-LICENSE` is copied alongside the backend; include it when distributing
+the library. MiniFB sources remain outside the tracked Simple sources.
+The cache survives `make clean`. Editing a makefile or rebuilding a backend
+does not fetch or check GitHub. Missing dependencies fail promptly with
+instructions to run `make libs` while online.
+
+`graphics-example` alone builds the executable without requiring MiniFB. It
+also demonstrates the diagnostic and exit status 1 when graphics is absent.
+`run-graphics` builds both, sets the backend path, and opens a window. Close it
+or release Escape to exit. Passing any argument to the executable makes it
+close after five polling iterations, for a quick smoke test.
+
+To run directly from PowerShell:
+
+```powershell
+$env:SIMPLE_MINIFB = (Resolve-Path build/release/x86_64_v2_win64/simple_minifb.dll).Path
+.\build\examples\x86_64_v2_win64\Graphics.exe
+```
+
+From Cygwin bash:
+
+```sh
+export SIMPLE_MINIFB="$PWD/build/release/x86_64_v2_win64/simple_minifb.dll"
+./build/examples/x86_64_v2_win64/Graphics.exe
+```
+
+For WSL, from the repository root:
+
+```sh
+wsl -d Ubuntu-24.04 -- bash -lc 'cd chapter31 && make graphics graphics-example CLZDIR=build/classes-linux CTAGS='
+```
+
+Inside WSL, set `SIMPLE_MINIFB` to the full path of the Linux `.so`, then run
+the Linux example with a working X11 display. For an automated virtual display:
+
+```sh
+SIMPLE_MINIFB="$PWD/build/release/x86_64_v2_SystemV/libsimple_minifb.so" \
+ xvfb-run -a build/examples/x86_64_v2_SystemV/Graphics smoke
+```
+
+With no `SIMPLE_MINIFB` setting, the runtime searches beside the executable,
+then its `../../release/<ABI>` and `../release/<ABI>` directories. These cover
+the examples and test build layouts regardless of the current directory.
+It also checks the current directory, `build/release/<ABI>`, and
+`chapter31/build/release/<ABI>` relative to the current directory. Thus built
+examples such as `Clock.exe` can be launched directly without configuration.
+
+`SIMPLE_MINIFB` overrides discovery; an invalid explicit path reports an error
+without falling back. `SIMPLE_MINIFB=off` disables graphics for headless tests.
+An absent, unloadable, or incompatible backend produces a diagnostic.
+A backend that loads but cannot create a window also reports an error cleanly.
+
+## Initial interface
+
+| Operation | Meaning |
+| --- | --- |
+| `open(title,width,height)` | Open one window; returns `OK` or `ERROR`. |
+| `present(pixels)` | Present a `u32[~]` of exactly `width*height` pixels, packed `0x00RRGGBB`. |
+| `poll(wait)` | Process input; `true` waits up to 17ms, waking for input. |
+| `wait(timeoutMs)` | Wait for input or timeout, then process events; 0 polls, -1 waits indefinitely. |
+| `mouseX()`, `mouseY()` | Current mouse position, origin at upper left. |
+| `button(LEFT)`, `key(code)` | Current pressed state; printable keys use uppercase ASCII. |
+| `close()` | Release native window and pixel storage; safe to repeat. |
+
+`present`, `poll`, and `wait` return `OK` (0), `CLOSED` (1, normal close/Escape), or
+`ERROR` (-1). Stop polling after either terminal result and call `close`.
+The initial input API samples held state; it is not a queue of every transition.
+
+Waits may return early; values below -1 are errors. Recheck the monotonic
+clock after waking. The [time library and animation example](time.md) combine
+this wait with cooperative timer deadlines. Backend API version 2 adds the
+wait operation; rebuild older MiniFB adapters from the cached sources.
+
+MiniFB retains its last framebuffer for repaint events. The adapter copies the
+Simple array into native-owned storage before presenting; it never retains a
+Simple heap pointer. Framebuffer allocation, the copy, and window/input calls
+are the only native work here. Drawing primitives and the games can be built
+in Simple on top of this boundary.
diff --git a/docs/chapter31/time.md b/docs/chapter31/time.md
new file mode 100644
index 00000000..04865626
--- /dev/null
+++ b/docs/chapter31/time.md
@@ -0,0 +1,119 @@
+# Time and single-threaded animation
+
+`sys.time` uses bare `i64` values: absolute instants are milliseconds since
+the Unix epoch in UTC; durations are milliseconds. Monotonic readings belong
+to a separate clock domain. Never subtract a wall-clock reading from a
+monotonic deadline.
+
+## Clocks and waits
+
+| Call | Result |
+| --- | --- |
+| `time.now()` | Unix-epoch milliseconds; may jump when the system clock changes. |
+| `time.monotonic()` | Monotonic milliseconds for elapsed time and deadlines. |
+| `time.nanoTime()` | Monotonic nanoseconds, with the clock's available resolution. |
+| `time.sleep(ms)` | Sleep for a nonnegative duration. |
+| `time.sleepUntil(deadline)` | Wait for a monotonic millisecond deadline; returns immediately if past. |
+| `time.remaining(deadline, current)` | Nonnegative difference, saturating on overflow. |
+
+Clock errors return `-1`; sleeps return zero on success or `-1` on error,
+with `sys.libc.errno()` available. Interrupted sleeps resume rather than
+returning early. Wakeup times are approximate, and these calls do not wake a
+suspended laptop. Sleeping blocks the one Simple execution thread; use
+`gfx.wait` when window input must remain responsive.
+
+`seconds(n)`, `minutes(n)`, `hours(n)`, and `days(n)` convert signed counts
+to milliseconds. `toSeconds(ms)`, `toMinutes(ms)`, `toHours(ms)`, and
+`toDays(ms)` truncate toward zero. A duration day is exactly 24 hours,
+independent of daylight saving. `toNanos(ms)` and `fromNanos(ns)` convert
+between millisecond and nanosecond units. Multiplication overflow returns
+`-1`. Constants `SECOND`, `MINUTE`, `HOUR`, and `DAY` are also available.
+
+## String interpolation
+
+Time formatting is built into the existing string interpolation syntax:
+
+| Format | Example | Named style |
+| --- | --- | --- |
+| `%t(instant)` or `%tF(instant)` | `2026-11-12T00:34:05.123Z` | `time.ISO` |
+| `%tr(instant)` | `12:34am` | `time.CLOCK12` |
+| `%tR(instant)` | `00:34` | `time.CLOCK24` |
+| `%tT(instant)` | `00:34:05` | `time.CLOCK24_SECONDS` |
+| `%tD(instant)` | `12/11/26` | `time.DATE` |
+| `%tM(duration)` | `02:03.456` | `time.ELAPSED` |
+
+For example, `"UTC %tR(sys.time.now())"`. Width and left alignment work as
+for strings: `%30t(instant)` and `%-30t(instant)`. Precision and numeric flags
+are rejected. `%tM` formats a duration, with total minutes rather than wrapping
+at an hour; negative durations keep their sign.
+
+Interpolation uses UTC, making its output stable across machines.
+`time.format(instant, style)` is the equivalent library call;
+`time.formatLocal(instant, style)` uses the system's local zone. Local ISO
+output includes a numeric offset instead of `Z` when nonzero, retaining
+offset seconds for historical zones. Invalid calendar values/styles produce
+`<invalid time>`. The ISO form retains all millisecond bits within the supported
+calendar range; short formats intentionally omit information.
+
+## Calendar values and absolute parsing
+
+`time.utc(instant)` and `time.local(instant)` return a `DateTime` with fields
+`year`, `month`, `day`, `hour`, `minute`, `second`, `millisecond`, `weekday`
+(Sunday = 0), `offsetSeconds` (east of UTC), and `daylight`. A zero year means
+conversion failed. `time.zoneName(instant)` returns the local abbreviation or
+an empty string on error. Local conversion uses the operating system's zone
+rules, including DST at that instant; no time-zone database is downloaded.
+
+`time.makeUTC(year, month, day, hour, minute, second, millisecond)` validates
+and constructs an instant, returning `-1` on invalid fields. `time.valid(...)`,
+`time.leap(year)`, and `time.monthDays(year, month)` support calendar checks.
+Calendar conversion supports Gregorian years 1 through 9999, bounded by
+`time.MIN` and `time.MAX`, and rejects leap seconds.
+
+`time.parse(text)` accepts strict absolute ISO text:
+`YYYY-MM-DDTHH:mm:ss[.SSS]Z` or the same with an explicit `+HH:MM`/`-HH:MM`
+offset (optionally with `:SS`). Fractions have exactly three digits. No relative
+time parsing, implicit local zone, locale-dependent parsing, or named-zone
+conversion is provided. Offsets must have hours 00–23 and minutes/seconds 00–59.
+Invalid text or an out-of-range result returns `-1`. By design, this sentinel
+is indistinguishable from the valid instant `1969-12-31T23:59:59.999Z`;
+formatting and calendar conversion still support that instant.
+
+## Cooperative timers
+
+Create `val timers = new sys.time.Timers();` on the game thread:
+
+| Operation | Meaning |
+| --- | --- |
+| `after(delay)` | Register a one-shot timer; return its positive ID, or `-1`. |
+| `every(period)` | Register a repeating timer; period must be positive. |
+| `afterAt(current, delay)`, `everyAt(current, period)` | Supply a monotonic reading explicitly, useful for deterministic tests. |
+| `cancel(id)` | Remove a timer; return whether it was present. |
+| `nextDeadline()` | Earliest monotonic deadline, or `-1` if empty. |
+| `poll(current)` | Return the next due ID, or `-1` if none. |
+
+Poll repeatedly to drain due timers. Deadlines order delivery, then registration
+ID breaks ties. Repeating timers advance from their previous deadline, skipping
+missed ticks to the first future deadline rather than drifting or replaying a
+backlog. Deadline overflow rejects registration or cancels a repeating timer.
+There are no callbacks or timer threads: the caller handles each returned ID.
+
+`gfx.wait(timeoutMs)` processes events after waiting for input or a timeout:
+zero polls, `-1` waits indefinitely, values below `-1` fail. It returns the same
+`OK`, `CLOSED`, or `ERROR` statuses as `gfx.poll`, and may wake early. Re-read the
+monotonic clock after each wake. This allows one thread to handle timers,
+input, simulation, and drawing without busy waiting.
+
+The [digital and analog clock example](examples/Clock.smp) combines these pieces. From
+the repository root, with graphics dependencies already cached:
+
+```sh
+make -C chapter31 clock-example CTAGS= # Build without opening a window.
+make -C chapter31 run-clock CTAGS= # Build the backend and run.
+```
+
+Click the button or press Space to switch between green seven-segment digits
+and an analog dial with smoothly moving hands. Both display system-local time;
+Q/Escape exits. Passing any argument directly to the executable closes it after
+ten frames, switching faces halfway through. Hands are computed from the current
+time, so delayed frames do not create a simulation catch-up backlog.
diff --git a/graphics.mk b/graphics.mk
new file mode 100644
index 00000000..5b0bce09
--- /dev/null
+++ b/graphics.mk
@@ -0,0 +1,70 @@
+# Optional MiniFB build, pinned independently of Simple's default build.
+# A C-only GDI/X11 subset: no OpenGL, C++ wrapper, examples, or CMake required.
+MINIFB_REV := f92be8e966f194004853c10196e704f4510f58d4
+MINIFB_SRC := lib/minifb-$(MINIFB_REV)
+MINIFB_ARCHIVE := lib/minifb-$(MINIFB_REV).tar.gz
+MINIFB_STAMP := $(MINIFB_SRC)/.simple-$(MINIFB_REV)
+MINIFB_COMMON := MiniFB_common MiniFB_cpu_x86 MiniFB_internal MiniFB_stretch \
+ MiniFB_stretch_sse2 MiniFB_stretch_avx2 MiniFB_stretch_neon MiniFB_timer MiniFB_utf8
+ifeq ($(OS),Windows_NT)
+ GRAPHICS_CC := x86_64-w64-mingw32-gcc
+ GRAPHICS_LIB := $(RELEASE)/$(CPU_ABI)/simple_minifb.dll
+ MINIFB_PLATFORM := windows/WinMiniFB
+ MINIFB_LIBS := -lgdi32 -luser32 -lwinmm
+ MINIFB_FLAGS := -D_WIN32_WINNT=0x0601
+else
+ GRAPHICS_CC := gcc
+ GRAPHICS_LIB := $(RELEASE)/$(CPU_ABI)/libsimple_minifb.so
+ MINIFB_PLATFORM := x11/X11MiniFB x11/X11MiniFB_scale_funcs x11/X11MiniFB_text \
+ MiniFB_linux MiniFB_evdev_keys
+ MINIFB_LIBS := -lX11 -lm
+ MINIFB_FLAGS := -fPIC -D_POSIX_C_SOURCE=200809L
+endif
+MINIFB_SOURCES = $(addprefix $(MINIFB_SRC)/src/,$(addsuffix .c,$(MINIFB_COMMON) $(MINIFB_PLATFORM)))
+GRAPHICS_EXAMPLE := $(GAME_DIR)/Graphics$(if $(filter Windows_NT,$(OS)),.exe,)
+GRAPHICAL_TICTACTOE := $(GAME_DIR)/TicTacToeGraphical$(if $(filter Windows_NT,$(OS)),.exe,)
+CLOCK_EXAMPLE := $(GAME_DIR)/Clock$(if $(filter Windows_NT,$(OS)),.exe,)
+
+.PHONY: clock-example run-clock
+clock-example: $(CLOCK_EXAMPLE)
+$(CLOCK_EXAMPLE): docs/examples/Clock.smp $(CLZDIR)/test/.ttag $(RELEASE_SYS) $(RELEASE_CRT) graphics.mk
+ @$(JVM) com.seaofnodes.simple.Simple --link --cpu $(CPU_PORT) --abi $(CALL_CONV) -L $(dir $(RELEASE_SYS)) -o $(GAME_DIR)/Clock.o $<
+run-clock: graphics clock-example
+ SIMPLE_MINIFB="$(abspath $(GRAPHICS_LIB))" $(CLOCK_EXAMPLE)
+
+.PHONY: graphics graphics-example run-graphics
+.PHONY: graphical-tictactoe run-graphical-tictactoe
+graphical-tictactoe: $(GRAPHICAL_TICTACTOE)
+$(GRAPHICAL_TICTACTOE): docs/examples/TicTacToeGraphical.smp $(CLZDIR)/test/.ttag $(RELEASE_SYS) $(RELEASE_CRT) graphics.mk
+ @$(JVM) com.seaofnodes.simple.Simple --link --cpu $(CPU_PORT) --abi $(CALL_CONV) -L $(dir $(RELEASE_SYS)) -o $(GAME_DIR)/TicTacToeGraphical.o $<
+
+run-graphical-tictactoe: graphics audio graphical-tictactoe
+ SIMPLE_MINIFB="$(abspath $(GRAPHICS_LIB))" SIMPLE_MINIAUDIO="$(abspath $(AUDIO_LIB))" $(GRAPHICAL_TICTACTOE)
+graphics: $(GRAPHICS_LIB)
+
+lib libs init: $(MINIFB_STAMP)
+
+$(MINIFB_ARCHIVE):
+ @$(call download-library,https://codeload.github.com/emoon/minifb/tar.gz/$(MINIFB_REV))
+
+$(MINIFB_STAMP): $(MINIFB_ARCHIVE)
+ @mkdir -p $(MINIFB_SRC)
+ @tar -xzf $< --strip-components=1 -C $(MINIFB_SRC)
+ @touch $@
+
+$(GRAPHICS_LIB): $(MINIFB_STAMP) src/main/c/simple_minifb.c src/main/c/simple_graphics.h graphics.mk
+ @echo "building " $@
+ @mkdir -p $(dir $@)
+ @$(GRAPHICS_CC) -shared -O2 -Wall -Wextra -Wno-unused-parameter -Wno-implicit-fallthrough $(MINIFB_FLAGS) \
+ -I$(MINIFB_SRC)/include -I$(MINIFB_SRC)/src \
+ src/main/c/simple_minifb.c $(MINIFB_SOURCES) $(MINIFB_LIBS) -o $@
+ @cp $(MINIFB_SRC)/LICENSE $(dir $@)MiniFB-LICENSE
+
+# This executable also builds without MiniFB, to exercise the missing-backend path.
+graphics-example: $(GRAPHICS_EXAMPLE)
+$(GRAPHICS_EXAMPLE): docs/examples/Graphics.smp $(CLZDIR)/test/.ttag $(RELEASE_SYS) $(RELEASE_CRT) graphics.mk
+ @$(JVM) com.seaofnodes.simple.Simple --link --cpu $(CPU_PORT) --abi $(CALL_CONV) -L $(dir $(RELEASE_SYS)) -o $(GAME_DIR)/Graphics.o $<
+
+# An interactive target, never part of default/tests.
+run-graphics: graphics graphics-example
+ SIMPLE_MINIFB="$(abspath $(GRAPHICS_LIB))" $(GRAPHICS_EXAMPLE)
diff --git a/src/main/c/simple_audio.c b/src/main/c/simple_audio.c
new file mode 100644
index 00000000..b8ed62de
--- /dev/null
+++ b/src/main/c/simple_audio.c
@@ -0,0 +1,40 @@
+#include "simple_audio.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <dlfcn.h>
+
+static const struct simple_audio_api *api;
+static void *library;
+
+int32_t simple_audio_open(void) {
+ const char *path = getenv("SIMPLE_MINIAUDIO");
+ if (path && strcmp(path,"off")==0) return -1;
+ if (!api) {
+ if (!path || !*path) return -1; // Optional: games may run silently.
+ library = dlopen(path,RTLD_NOW | RTLD_LOCAL);
+ if (!library) {
+ fprintf(stderr,"Simple audio: cannot load backend '%s': %s\n",path,dlerror());
+ return -1;
+ }
+ const struct simple_audio_api *(*get_api)(void) =
+ (const struct simple_audio_api *(*)(void))dlsym(library,"simple_miniaudio_api");
+ const struct simple_audio_api *candidate = get_api ? get_api() : NULL;
+ if (!candidate || candidate->version!=SIMPLE_AUDIO_VERSION || !candidate->open ||
+ !candidate->load || !candidate->play || !candidate->stop ||
+ !candidate->playing || !candidate->close) {
+ fprintf(stderr,"Simple audio: incompatible backend; rebuild with make audio.\n");
+ dlclose(library);
+ library=NULL;
+ return -1;
+ }
+ api=candidate;
+ atexit(api->close);
+ }
+ return api->open();
+}
+int32_t simple_audio_load(const int16_t *samples, uint32_t count) { return api ? api->load(samples,count) : -1; }
+int32_t simple_audio_play(int32_t sound) { return api ? api->play(sound) : -1; }
+int32_t simple_audio_stop(void) { return api ? api->stop() : 0; }
+int32_t simple_audio_playing(void) { return api ? api->playing() : 0; }
+int32_t simple_audio_close(void) { if (api) api->close(); return 0; }
diff --git a/src/main/c/simple_audio.h b/src/main/c/simple_audio.h
new file mode 100644
index 00000000..77cce530
--- /dev/null
+++ b/src/main/c/simple_audio.h
@@ -0,0 +1,16 @@
+#ifndef SIMPLE_AUDIO_H
+#define SIMPLE_AUDIO_H
+#include <stdint.h>
+
+// Main-thread API. Samples are copied; no Simple heap pointer is retained.
+struct simple_audio_api {
+ int32_t version;
+ int32_t (*open)(void);
+ int32_t (*load)(const int16_t *, uint32_t);
+ int32_t (*play)(int32_t);
+ int32_t (*stop)(void);
+ int32_t (*playing)(void);
+ void (*close)(void);
+};
+#define SIMPLE_AUDIO_VERSION 1
+#endif
diff --git a/src/main/c/simple_graphics.c b/src/main/c/simple_graphics.c
new file mode 100644
index 00000000..40d66ba9
--- /dev/null
+++ b/src/main/c/simple_graphics.c
@@ -0,0 +1,102 @@
+#define _POSIX_C_SOURCE 200809L
+#include "simple_graphics.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <dlfcn.h>
+#include <unistd.h>
+
+#ifdef __CYGWIN__
+#define BACKEND_NAME "simple_minifb.dll"
+#define BACKEND_ABI "x86_64_v2_win64"
+#else
+#define BACKEND_NAME "libsimple_minifb.so"
+#define BACKEND_ABI "x86_64_v2_SystemV"
+#endif
+
+// Executable-relative paths work even when launched outside the checkout.
+// Working-directory paths also cover test executables and chapter/root shells.
+static void *find_backend(void) {
+ char executable[4096], path[8192];
+ ssize_t n=readlink("/proc/self/exe",executable,sizeof(executable)-1);
+ if (n>0 && n<(ssize_t)sizeof(executable)-1) {
+ executable[n]=0;
+ char *slash=strrchr(executable,'/');
+ if (slash) {
+ *slash=0;
+ const char *relative[]={"/" BACKEND_NAME,
+ "/../../release/" BACKEND_ABI "/" BACKEND_NAME,
+ "/../release/" BACKEND_ABI "/" BACKEND_NAME};
+ for (unsigned i=0;i<sizeof(relative)/sizeof(*relative);++i) {
+ snprintf(path,sizeof(path),"%s%s",executable,relative[i]);
+ void *handle=dlopen(path,RTLD_NOW | RTLD_LOCAL);
+ if (handle) return handle;
+ }
+ }
+ }
+ const char *relative[]={"./" BACKEND_NAME,
+ "./build/release/" BACKEND_ABI "/" BACKEND_NAME,
+ "./chapter31/build/release/" BACKEND_ABI "/" BACKEND_NAME};
+ for (unsigned i=0;i<sizeof(relative)/sizeof(*relative);++i) {
+ void *handle=dlopen(relative[i],RTLD_NOW | RTLD_LOCAL);
+ if (handle) return handle;
+ }
+ return NULL;
+}
+
+// No MiniFB headers or link dependency in the default runtime. Cygwin's
+// dlopen also loads native Windows DLLs; both use the x64 Windows C ABI.
+static const struct simple_graphics_api *api;
+static void *library;
+
+static int load_graphics(void) {
+ // Explicitly disabling graphics also makes headless tests deterministic.
+ const char *path = getenv("SIMPLE_MINIFB");
+ if (path && strcmp(path, "off") == 0) {
+ fprintf(stderr, "Simple graphics: disabled (SIMPLE_MINIFB=off).\n");
+ return -1;
+ }
+ if (api) return 0;
+ int explicit_path=path && *path;
+ library = explicit_path ? dlopen(path, RTLD_NOW | RTLD_LOCAL) : find_backend();
+ if (!library) {
+ if (explicit_path)
+ fprintf(stderr, "Simple graphics: cannot load MiniFB backend '%s': %s\n", path, dlerror());
+ else
+ fprintf(stderr, "Simple graphics: MiniFB backend not found beside the executable or in build/release/"
+ BACKEND_ABI ". Run make graphics, or set SIMPLE_MINIFB to its full path.\n");
+ return -1;
+ }
+ if (!explicit_path) path="automatically discovered backend";
+ const struct simple_graphics_api *(*get_api)(void) =
+ (const struct simple_graphics_api *(*)(void))dlsym(library, "simple_minifb_api");
+ const struct simple_graphics_api *candidate = get_api ? get_api() : NULL;
+ if (!candidate || candidate->version != SIMPLE_GRAPHICS_VERSION ||
+ !candidate->open || !candidate->present || !candidate->poll ||
+ !candidate->mouse_x || !candidate->mouse_y || !candidate->button ||
+ !candidate->key || !candidate->close || !candidate->wait) {
+ fprintf(stderr, "Simple graphics: incompatible MiniFB backend '%s'. Rebuild with make graphics.\n", path);
+ dlclose(library);
+ library = NULL;
+ return -1;
+ }
+ api = candidate;
+ // Keep the library loaded until process exit; its window callbacks may
+ // still be registered. Closing a window releases its native resources.
+ atexit(api->close);
+ return 0;
+}
+
+int32_t simple_gfx_open(const char *title, uint32_t width, uint32_t height) {
+ return load_graphics() ? -1 : api->open(title, width, height);
+}
+int32_t simple_gfx_present(const uint32_t *pixels, uint32_t count) {
+ return api ? api->present(pixels, count) : -1;
+}
+int32_t simple_gfx_poll(int32_t wait) { return api ? api->poll(wait) : -1; }
+int32_t simple_gfx_mouse_x(void) { return api ? api->mouse_x() : 0; }
+int32_t simple_gfx_mouse_y(void) { return api ? api->mouse_y() : 0; }
+int32_t simple_gfx_button(int32_t button) { return api ? api->button(button) : 0; }
+int32_t simple_gfx_key(int32_t key) { return api ? api->key(key) : 0; }
+int32_t simple_gfx_close(void) { if (api) api->close(); return 0; }
+int32_t simple_gfx_wait(int64_t timeout) { return api ? api->wait(timeout) : -1; }
diff --git a/src/main/c/simple_graphics.h b/src/main/c/simple_graphics.h
new file mode 100644
index 00000000..370256be
--- /dev/null
+++ b/src/main/c/simple_graphics.h
@@ -0,0 +1,20 @@
+#ifndef SIMPLE_GRAPHICS_H
+#define SIMPLE_GRAPHICS_H
+#include <stdint.h>
+
+// Private, versioned boundary between the tiny runtime loader and MiniFB.
+// All calls run on the main thread. Pixel memory is borrowed for present only.
+struct simple_graphics_api {
+ int32_t version;
+ int32_t (*open)(const char *, uint32_t, uint32_t);
+ int32_t (*present)(const uint32_t *, uint32_t);
+ int32_t (*poll)(int32_t);
+ int32_t (*mouse_x)(void);
+ int32_t (*mouse_y)(void);
+ int32_t (*button)(int32_t);
+ int32_t (*key)(int32_t);
+ void (*close)(void);
+ int32_t (*wait)(int64_t);
+};
+#define SIMPLE_GRAPHICS_VERSION 2
+#endif
diff --git a/src/main/c/simple_miniaudio.c b/src/main/c/simple_miniaudio.c
new file mode 100644
index 00000000..03dbadd7
--- /dev/null
+++ b/src/main/c/simple_miniaudio.c
@@ -0,0 +1,123 @@
+#include "simple_audio.h"
+#define MA_NO_DECODING
+#define MA_NO_ENCODING
+#define MA_NO_RESOURCE_MANAGER
+#define MA_NO_NODE_GRAPH
+#define MA_NO_ENGINE
+#define MINIAUDIO_IMPLEMENTATION
+#include <miniaudio.h>
+#include <math.h>
+#include <stdlib.h>
+#include <string.h>
+#include <stdio.h>
+
+#define RATE 48000
+#define SLOTS 32
+static ma_device device;
+static ma_mutex mutex;
+static int opened, active;
+static uint32_t cursor;
+static struct { int16_t *pcm; uint32_t count; } sounds[SLOTS];
+
+// Only native-owned immutable PCM is read on the audio thread. The lock covers
+// a bounded copy, never allocation, synthesis, device calls, or Simple code.
+static void output(ma_device *unused, void *out, const void *in, ma_uint32 frames) {
+ (void)unused; (void)in;
+ memset(out,0,frames*sizeof(int16_t));
+ ma_mutex_lock(&mutex);
+ if (active) {
+ uint32_t count=sounds[active].count-cursor;
+ if (count>frames) count=frames;
+ memcpy(out,sounds[active].pcm+cursor,count*sizeof(int16_t));
+ cursor+=count;
+ if (cursor==sounds[active].count) active=0;
+ }
+ ma_mutex_unlock(&mutex);
+}
+static int32_t load(const int16_t *pcm, uint32_t count) {
+ if (!opened || !pcm || !count || count>RATE*10) return -1;
+ int slot;
+ for (slot=4; slot<SLOTS && sounds[slot].pcm; ++slot) {}
+ if (slot==SLOTS) return -1;
+ int16_t *copy=malloc(count*sizeof(*copy));
+ if (!copy) return -1;
+ memcpy(copy,pcm,count*sizeof(*copy));
+ ma_mutex_lock(&mutex);
+ sounds[slot].pcm=copy; sounds[slot].count=count;
+ ma_mutex_unlock(&mutex);
+ return slot;
+}
+static int32_t play(int32_t sound) {
+ if (!opened || sound<=0 || sound>=SLOTS || !sounds[sound].pcm) return -1;
+ ma_mutex_lock(&mutex);
+ active=sound; cursor=0;
+ ma_mutex_unlock(&mutex);
+ return 0;
+}
+static int32_t stop(void) {
+ if (opened) { ma_mutex_lock(&mutex); active=0; ma_mutex_unlock(&mutex); }
+ return 0;
+}
+static int32_t playing(void) {
+ if (!opened) return 0;
+ ma_mutex_lock(&mutex);
+ int result=active!=0;
+ ma_mutex_unlock(&mutex);
+ return result;
+}
+static void close_audio(void) {
+ if (!opened) return;
+ ma_device_uninit(&device); // Joins callback before freeing buffers/mutex.
+ ma_mutex_uninit(&mutex);
+ for (int i=1;i<SLOTS;++i) {
+ free(sounds[i].pcm); sounds[i].pcm=NULL; sounds[i].count=0;
+ }
+ opened=0; active=0; cursor=0;
+}
+static int tone(int slot, int descending) {
+ uint32_t note=slot==1 ? RATE*7/100 : RATE*16/100;
+ int notes=slot==1 ? 1 : 3;
+ sounds[slot].count=note*notes;
+ sounds[slot].pcm=calloc(note*notes,sizeof(int16_t));
+ if (!sounds[slot].pcm) return -1;
+ const double pitches[3]={523.251,659.255,783.991};
+ for (int n=0;n<notes;++n) {
+ double hz=slot==1 ? 440.0 : pitches[descending ? 2-n : n];
+ for (uint32_t i=0;i<note;++i) {
+ double t=(double)i/note;
+ double envelope=sin(3.141592653589793*t);
+ sounds[slot].pcm[n*note+i]=(int16_t)(3200*envelope*envelope*sin(6.283185307179586*hz*i/RATE));
+ }
+ }
+ return 0;
+}
+static int32_t open_audio(void) {
+ if (opened) return 0;
+ if (ma_mutex_init(&mutex)!=MA_SUCCESS) return -1;
+ ma_device_config config=ma_device_config_init(ma_device_type_playback);
+ config.playback.format=ma_format_s16;
+ config.playback.channels=1;
+ config.sampleRate=RATE;
+ config.dataCallback=output;
+#ifdef SIMPLE_AUDIO_TEST_NULL
+ ma_backend backend=ma_backend_null;
+ ma_result result=ma_device_init_ex(&backend,1,NULL,&config,&device);
+#else
+ ma_result result=ma_device_init(NULL,&config,&device);
+#endif
+ if (result!=MA_SUCCESS) {
+ ma_mutex_uninit(&mutex);
+ fprintf(stderr,"Simple audio: no playback device (%d); continuing silently.\n",result);
+ return -1;
+ }
+ opened=1;
+ if (tone(1,0) || tone(2,0) || tone(3,1) || ma_device_start(&device)!=MA_SUCCESS) {
+ close_audio(); return -1;
+ }
+ return 0;
+}
+static const struct simple_audio_api api={SIMPLE_AUDIO_VERSION,open_audio,load,play,stop,playing,close_audio};
+#ifdef _WIN32
+__declspec(dllexport)
+#endif
+const struct simple_audio_api *simple_miniaudio_api(void) { return &api; }
diff --git a/src/main/c/simple_minifb.c b/src/main/c/simple_minifb.c
new file mode 100644
index 00000000..2117f0d2
--- /dev/null
+++ b/src/main/c/simple_minifb.c
@@ -0,0 +1,117 @@
+#include "simple_graphics.h"
+#include <MiniFB.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <limits.h>
+#ifdef _WIN32
+#include <windows.h>
+#else
+#include <errno.h>
+#include <time.h>
+#include <poll.h>
+#include "WindowData.h"
+#include "x11/WindowData_X11.h"
+#endif
+
+// One fixed-size window for now. No Simple pointer survives a native call:
+// MiniFB retains its last draw buffer for OS repaint events, so copy it here.
+static struct mfb_window *window;
+static uint32_t *frame;
+static uint32_t width, height;
+
+// The C-only build never installs the upstream C++ callback wrappers.
+void release_cpp_stub(struct mfb_window *unused) { (void)unused; }
+
+static void close_window(void) {
+ if (window) {
+ mfb_close(window); // Requests close.
+ mfb_update_events(window); // Actually destroys the window.
+ window = NULL;
+ }
+ free(frame);
+ frame = NULL;
+}
+
+static int32_t open_window(const char *title, uint32_t w, uint32_t h) {
+ if (window || !w || !h || w > UINT32_MAX / 4 ||
+ (uint64_t)w * h > UINT32_MAX || (uint64_t)w * h > SIZE_MAX / 4) {
+ fprintf(stderr, "Simple graphics: invalid dimensions or a window is already open.\n");
+ return -1;
+ }
+ frame = calloc((size_t)w * h, sizeof(*frame));
+ if (!frame) {
+ fprintf(stderr, "Simple graphics: cannot allocate the display buffer.\n");
+ return -1;
+ }
+ window = mfb_open(title, w, h);
+ if (!window) {
+ fprintf(stderr, "Simple graphics: MiniFB could not open a window (is a graphical display available?).\n");
+ close_window();
+ return -1;
+ }
+ width = w;
+ height = h;
+ return 0;
+}
+
+static int32_t status(mfb_update_state state) {
+ if (state == MFB_STATE_OK) return 0;
+ if (state == MFB_STATE_EXIT) {
+ window = NULL; // MiniFB has already destroyed it.
+ close_window();
+ return 1;
+ }
+ fprintf(stderr, "Simple graphics: MiniFB update failed (%d).\n", state);
+ close_window();
+ return -1;
+}
+
+static int32_t present(const uint32_t *pixels, uint32_t count) {
+ if (!window) return 1;
+ if (!pixels || count != (uint64_t)width * height) {
+ fprintf(stderr, "Simple graphics: pixel array length must equal width * height.\n");
+ return -1;
+ }
+ memcpy(frame, pixels, (size_t)count * sizeof(*frame));
+ return status(mfb_update_ex(window, frame, width, height));
+}
+
+static int32_t wait_events(int64_t timeout) {
+ if (!window) return 1;
+ if (timeout < -1) return -1;
+ // May return early; the game recomputes its deadlines after every wakeup.
+ int ms=timeout>INT_MAX ? INT_MAX : (int)timeout;
+#ifdef _WIN32
+ if (MsgWaitForMultipleObjectsEx(0,NULL,ms<0 ? INFINITE : (DWORD)ms,
+ QS_ALLINPUT,MWMO_INPUTAVAILABLE)==WAIT_FAILED) return -1;
+#else
+ // The pinned MiniFB X11 backend owns this connection. Check already queued
+ // Xlib events before waiting on its socket; no new event thread is needed.
+ SWindowData *data=(SWindowData *)window;
+ SWindowData_X11 *native=(SWindowData_X11 *)data->specific;
+ if (!XPending(native->display)) {
+ struct pollfd fd={ConnectionNumber(native->display),POLLIN,0};
+ if (poll(&fd,1,ms)<0 && errno!=EINTR) return -1;
+ }
+#endif
+ return status(mfb_update_events(window));
+}
+static int32_t poll_events(int32_t wait) { return wait_events(wait ? 17 : 0); }
+static int32_t mouse_x(void) { return window ? mfb_get_mouse_x(window) : 0; }
+static int32_t mouse_y(void) { return window ? mfb_get_mouse_y(window) : 0; }
+static int32_t button(int32_t b) {
+ return window && b >= 0 && b < 8 ? mfb_get_mouse_button_buffer(window)[b] != 0 : 0;
+}
+static int32_t key(int32_t k) {
+ return window && k >= 0 && k <= MFB_KB_KEY_LAST ? mfb_get_key_buffer(window)[k] != 0 : 0;
+}
+
+static const struct simple_graphics_api api = {
+ SIMPLE_GRAPHICS_VERSION, open_window, present, poll_events,
+ mouse_x, mouse_y, button, key, close_window, wait_events
+};
+#ifdef _WIN32
+__declspec(dllexport)
+#endif
+const struct simple_graphics_api *simple_minifb_api(void) { return &api; }
diff --git a/src/main/c/simple_time.c b/src/main/c/simple_time.c
new file mode 100644
index 00000000..2e579922
--- /dev/null
+++ b/src/main/c/simple_time.c
@@ -0,0 +1,61 @@
+#define _POSIX_C_SOURCE 200809L
+#include <time.h>
+#include <stdint.h>
+#include <errno.h>
+
+// Fixed-width bridge; no native struct layout or retained Simple pointers.
+static int64_t clock_value(clockid_t clock, int64_t scale) {
+ struct timespec t;
+ if (clock_gettime(clock,&t)) return -1;
+ if (t.tv_sec>INT64_MAX/scale || t.tv_sec<INT64_MIN/scale) {
+ errno=EOVERFLOW; return -1;
+ }
+ int64_t base=(int64_t)t.tv_sec*scale;
+ int64_t fraction=t.tv_nsec/(1000000000/scale);
+ if (base>INT64_MAX-fraction) { errno=EOVERFLOW; return -1; }
+ return base+fraction;
+}
+int64_t simple_time_now(void) { return clock_value(CLOCK_REALTIME,1000); }
+int64_t simple_time_mono(void) { return clock_value(CLOCK_MONOTONIC,1000); }
+int64_t simple_time_nano(void) { return clock_value(CLOCK_MONOTONIC,1000000000); }
+int32_t simple_time_sleep(int64_t ms, int32_t absolute) {
+ if (ms<0) { errno=EINVAL; return -1; }
+ struct timespec t={ms/1000,(ms%1000)*1000000};
+ int result;
+ if (!absolute) {
+ // Retry the remaining duration after signals; no busy waiting.
+ do { result=nanosleep(&t,&t); } while (result && errno==EINTR);
+ return result;
+ }
+ do { result=clock_nanosleep(CLOCK_MONOTONIC,TIMER_ABSTIME,&t,NULL); } while (result==EINTR);
+ if (result) { errno=result; return -1; }
+ return 0;
+}
+static int leap(int y) { return y%4==0 && (y%100!=0 || y%400==0); }
+static int local_fields(int64_t ms, struct tm *local, struct tm *utc) {
+ tzset();
+ int64_t sec=ms/1000;
+ if (ms%1000<0) --sec;
+ time_t t=(time_t)sec;
+ if ((int64_t)t!=sec || !localtime_r(&t,local) || !gmtime_r(&t,utc)) return -1;
+ if (local->tm_year < -1899 || local->tm_year > 8099) { errno=EOVERFLOW; return -1; }
+ return 0;
+}
+int32_t simple_time_local(int64_t ms, int64_t *out) {
+ struct tm t,u;
+ if (local_fields(ms,&t,&u)) return -1;
+ int days=t.tm_yday-u.tm_yday;
+ if (t.tm_year>u.tm_year) days+=365+leap(u.tm_year+1900);
+ if (t.tm_year<u.tm_year) days-=365+leap(t.tm_year+1900);
+ int offset=days*86400+(t.tm_hour-u.tm_hour)*3600+(t.tm_min-u.tm_min)*60+t.tm_sec-u.tm_sec;
+ out[0]=t.tm_year+1900; out[1]=t.tm_mon+1; out[2]=t.tm_mday;
+ out[3]=t.tm_hour; out[4]=t.tm_min; out[5]=t.tm_sec;
+ out[6]=(ms%1000+1000)%1000; out[7]=t.tm_wday;
+ out[8]=offset; out[9]=t.tm_isdst>0;
+ return 0;
+}
+int32_t simple_time_zone(int64_t ms, char *out, uint32_t capacity) {
+ struct tm t,u;
+ if (!capacity || local_fields(ms,&t,&u)) return -1;
+ return (int32_t)strftime(out,capacity,"%Z",&t);
+}
diff --git a/src/main/java/com/seaofnodes/simple/README.md b/src/main/java/com/seaofnodes/simple/README.md
index af86f21e..a8916aa8 100644
--- a/src/main/java/com/seaofnodes/simple/README.md
+++ b/src/main/java/com/seaofnodes/simple/README.md
@@ -2,7 +2,7 @@
| Directory | Contents |
| --- | --- |
-| `parser/` | Recursive-descent grammar routines, lexing, name resolution, and construction of the initial IR. `Var` describes a lexical binding. See the [grammar map](../../../../../../docs/chapter30/30-grammar.md). |
+| `parser/` | Recursive-descent grammar routines, lexing, name resolution, and construction of the initial IR. `Var` describes a lexical binding. See the [grammar map](../../../../../../docs/chapter31/31-grammar.md). |
| `node/` | Sea-of-Nodes IR nodes: graph edges, type computation, and local peephole rewrites. |
| `node/cpus/` | Machine-specific nodes and instruction selection support for `arm`, `riscv`, and `x86_64_v2`. |
| `codegen/` | Compilation driver and passes: module loading, optimization, scheduling, register allocation, serialization, and machine-code emission. `IterPeeps` runs the peephole worklist. |
diff --git a/src/main/java/com/seaofnodes/simple/codegen/Encoding.java b/src/main/java/com/seaofnodes/simple/codegen/Encoding.java
index b07a8fec..eb62c36a 100644
--- a/src/main/java/com/seaofnodes/simple/codegen/Encoding.java
+++ b/src/main/java/com/seaofnodes/simple/codegen/Encoding.java
@@ -39,7 +39,9 @@ public class Encoding implements com.seaofnodes.isa.CodeSink {
public final BAOS _bits = new BAOS(); // Instructions / encodings
public final BAOS _cpool = new BAOS(); // Constant r/o pool
public final BAOS _sdata = new BAOS(); // Static r/w pool
- public final HashMap<Node,Relo> _bigCons = new HashMap<>();
+ // Relocations belong to instruction occurrences, including equal constant
+ // loads cloned by scheduling/register allocation. Structural keys lose sites.
+ public final IdentityHashMap<Node,Relo> _bigCons = new IdentityHashMap<>();
public final StaticData _data;
public final HashMap<String,Integer> _externalAddresses = new HashMap<>();
@@ -157,7 +159,7 @@ public class Encoding implements com.seaofnodes.isa.CodeSink {
// Nodes need "relocation" patching; things done after code is placed.
// Record src and dst Nodes.
- private final HashMap<Node,CFGNode> _internals = new HashMap<>();
+ private final IdentityHashMap<Node,CFGNode> _internals = new IdentityHashMap<>();
// Source is a Call, destination in the Fun.
public Encoding relo( CallNode call ) {
FunNode fun = _code.link(call.tfp());
@@ -187,13 +189,13 @@ public class Encoding implements com.seaofnodes.isa.CodeSink {
}
// External references only located by Strings a link-time
- public final HashMap<Node,String> _externals = new HashMap<>();
+ public final IdentityHashMap<Node,String> _externals = new IdentityHashMap<>();
public Encoding external( Node n, String extern ) {
_externals.put(n,extern);
return this;
}
- public final HashMap<Node,String> _externData = new HashMap<>();
+ public final IdentityHashMap<Node,String> _externData = new IdentityHashMap<>();
public void externalData(Node n, String name) { _externData.put(n,name); }
// Store t as a 32/64 bit constant in the code space; generate RIP-relative
diff --git a/src/main/java/com/seaofnodes/simple/codegen/Serialize.java b/src/main/java/com/seaofnodes/simple/codegen/Serialize.java
index ae4ecb74..79bebe1f 100644
--- a/src/main/java/com/seaofnodes/simple/codegen/Serialize.java
+++ b/src/main/java/com/seaofnodes/simple/codegen/Serialize.java
@@ -417,7 +417,7 @@ public class Serialize {
assert elf._clz._name==strs[1/*skip null ptr*/];
for( Type type : types )
if( type instanceof TypeStruct ts )
- Parser.TYPES.put(ts._name,ts);
+ installType(ts);
for( Type type : types ) {
if( !(type instanceof TypeStruct ts) || !TypeStruct.startsClzPrefix(ts._name) )
continue;
@@ -429,7 +429,7 @@ public class Serialize {
elf._pubTypes = new TypeStruct[npubTypes];
for( int i=0; i<npubTypes; i++ ) {
TypeStruct ts = elf._pubTypes[i] = (TypeStruct)types[pubTypeIds[i]];
- Parser.TYPES.put(ts._name,ts);
+ installType(ts);
}
elf._functions = new FunctionSummary[bais.packed4()];
for( int i = 0; i<elf._functions.length; i++ ) {
@@ -444,6 +444,14 @@ public class Serialize {
return elf._types = new TypeRead(types,strs,fileAliases,fileFidxs,fileRpcs);
}
+ private static void installType(TypeStruct ts) {
+ // TYPES names the mutable array declaration. Read-only arrays in
+ // imported signatures keep their own types, but must not replace that
+ // declaration: closeOver looks up its mutable variant by name.
+ if( ts.isAry() ) ts = ts.replace(ts.field("[]").makeFrom(false));
+ Parser.TYPES.put(ts._name,ts);
+ }
+
static void readAll( CodeGen code, ElfReader elf, GlobalBits aliases, GlobalBits fidxs, GlobalBits rpcs ) {
BAOS bais = elf._bais;
TypeRead tread = readTypes(code, elf, aliases, fidxs, rpcs);
diff --git a/src/main/java/com/seaofnodes/simple/node/LoadNode.java b/src/main/java/com/seaofnodes/simple/node/LoadNode.java
index eb78ed1f..35db4c2f 100644
--- a/src/main/java/com/seaofnodes/simple/node/LoadNode.java
+++ b/src/main/java/com/seaofnodes/simple/node/LoadNode.java
@@ -364,15 +364,13 @@ public class LoadNode extends MemOpNode {
if( ptr instanceof PhiNode pphi && pphi.region() == r )
return true;
- // No, so can we lift this ptr?
- CFGNode cptr = ptr.cfg0();
- if( cptr != null )
- // Pointer is controlled high
- // TODO: Really needs to be the LCA of all inputs is high
- return cptr.idepth() <= r.idepth();
-
- // Dunno without a longer walk
- return false;
+ // Each copied Load needs its pointer before the Region, including
+ // a loop's entry arm. input 0 alone misses floating Loads whose own
+ // pointer was produced inside the loop.
+ CFGNode pinned = ptr.cfg0();
+ if( pinned==null || pinned.idepth()>r.idepth() ) return false;
+ CFGNode cptr = ptr instanceof PhiNode phi ? phi.region() : CFGNode.earlyCFG(ptr);
+ return cptr==null || cptr.dominates(r.idom(memphi),memphi);
}
@Override public Parser.ParseException err() {
diff --git a/src/main/java/com/seaofnodes/simple/node/MemMergeNode.java b/src/main/java/com/seaofnodes/simple/node/MemMergeNode.java
index aa3f6d57..19420839 100644
--- a/src/main/java/com/seaofnodes/simple/node/MemMergeNode.java
+++ b/src/main/java/com/seaofnodes/simple/node/MemMergeNode.java
@@ -20,8 +20,20 @@ public class MemMergeNode extends Node {
public MemMergeNode( Node ...nodes) { super(nodes); _type = TypeMem.BOT; }
public MemMergeNode(MemMergeNode mem) { super(mem); }
@Override public Tag serialTag() { return Tag.MemMerge; }
- public void packed( BAOS baos, HashMap<String,Integer> strs, HashMap<Type,Integer> types, IdentityHashMap<Node, Integer> anodes ) { baos.packed1(nIns()); }
- static Node make( BAOS bais ) { Node mem = new MemMergeNode(); mem.setDefX(bais.packed1()-1,null); return mem; }
+ public void packed( BAOS baos, HashMap<String,Integer> strs, HashMap<Type,Integer> types, IdentityHashMap<Node, Integer> anodes ) {
+ // A memory merge always has inputs. Zero escapes a wide alias table,
+ // preserving every existing nonzero single-byte count.
+ assert nIns()>0;
+ baos.packed1(nIns()<=255 ? nIns() : 0);
+ if( nIns()>255 ) baos.packed4(nIns());
+ }
+ static Node make( BAOS bais ) {
+ int count=bais.packed1();
+ if( count==0 ) count=bais.packed4();
+ Node mem=new MemMergeNode();
+ mem.setDefX(count-1,null);
+ return mem;
+ }
@Override public String label() { return "ALLMEM"; }
@Override public boolean isMem() { return true; }
diff --git a/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/JmpX86.java b/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/JmpX86.java
index 10cee059..ae725659 100644
--- a/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/JmpX86.java
+++ b/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/JmpX86.java
@@ -9,6 +9,7 @@ import com.seaofnodes.simple.util.Utils;
// Jump on flags, uses flags
public class JmpX86 extends IfNode implements MachNode, RIPRelSize {
String _bop;
+ boolean _unsigned, _floating, _unorderedTaken;
JmpX86( IfNode iff, String bop ) {
super(iff);
_bop = bop;
@@ -23,29 +24,47 @@ public class JmpX86 extends IfNode implements MachNode, RIPRelSize {
if( set instanceof SetX86 setx && (cmp instanceof CmpX86 || cmp instanceof CmpIX86 || cmp instanceof CmpMemX86 || cmp instanceof CmpFX86) ) {
_inputs.set( 1, cmp );
_bop = setx._bop;
+ _unsigned = setx._unsigned;
+ _floating = cmp instanceof CmpFX86;
+ _unorderedTaken = _bop.equals("!=");
} else
throw Utils.TODO();
}
@Override public RegMask regmap(int i) { assert i==1; return x86_64_v2.FLAGS_MASK; }
@Override public RegMask outregmap() { return null; }
- @Override public void negate() { _bop = negate(_bop); }
+ @Override public void negate() {
+ _bop = negate(_bop);
+ if( _floating ) _unorderedTaken = !_unorderedTaken;
+ }
+
+ private int condition() { return x86_64_v2.jumpop(_bop,_unsigned)&15; }
@Override public void encoding( Encoding enc ) {
if( in(1)==null && _bop=="!=" ) return;
- X86.branch(enc,in(1)==null ? -1 : x86_64_v2.jumpop(_bop)&15);
+ // UCOMISD sets CF/ZF/PF, not signed-integer SF/OF. Handle unordered
+ // separately, including when layout has negated the original test.
+ if( _floating ) {
+ if( _unorderedTaken ) enc.add1(0x0F).add1(0x8A).add4(0); // JP target
+ else enc.add1(0x7A).add1(0); // JP past the ordered jump
+ }
+ X86.branch(enc,in(1)==null ? -1 : condition());
enc.jump(this,cproj(0));
}
// Delta is from opcode start, but X86 measures from the end of the 2-byte encoding
@Override public byte encSize(int delta) {
if( in(1)==null && _bop=="!=" ) return 0;
- return X86.branchSize(delta,in(1)!=null);
+ int prefix = !_floating ? 0 : _unorderedTaken ? 6 : 2;
+ return (byte)(prefix + X86.branchSize(delta-prefix,in(1)!=null));
}
// Delta is from opcode start
@Override public void patch( Encoding enc, int opStart, int opLen, int delta ) {
assert !(in(1)==null && _bop=="!=");
- X86.patchBranch(enc.bits(),opStart,opLen,delta,in(1)==null ? -1 : x86_64_v2.jumpop(_bop)&15);
+ int prefix = !_floating ? 0 : _unorderedTaken ? 6 : 2;
+ if( _floating )
+ X86.patchBranch(enc.bits(),opStart,prefix,_unorderedTaken ? delta : opLen,10);
+ X86.patchBranch(enc.bits(),opStart+prefix,opLen-prefix,delta-prefix,in(1)==null ? -1 : condition());
}
@Override public void asm(CodeGen code, SB sb) {
diff --git a/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/NegX86.java b/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/NegX86.java
index 86610f6d..4494985d 100644
--- a/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/NegX86.java
+++ b/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/NegX86.java
@@ -9,7 +9,7 @@ public class NegX86 extends MachConcreteNode implements MachNode {
NegX86(MinusNode not) { super(not); }
@Override public String op() { return "neg"; }
@Override public RegMask regmap(int i) { return x86_64_v2.RMASK; }
- @Override public RegMask outregmap() { return x86_64_v2.RMASK; }
+ @Override public RegMask outregmap() { return x86_64_v2.WMASK; }
@Override public RegMask killmap() { return x86_64_v2.FLAGS_MASK; }
@Override public int twoAddress() { return 1; }
diff --git a/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/NotX86.java b/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/NotX86.java
index 1e4e7c26..1291cf78 100644
--- a/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/NotX86.java
+++ b/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/NotX86.java
@@ -9,7 +9,7 @@ public class NotX86 extends MachConcreteNode implements MachNode {
NotX86(NotNode not) { super(not); }
@Override public String op() { return "not"; }
@Override public RegMask regmap(int i) { return x86_64_v2.RMASK; }
- @Override public RegMask outregmap() { return x86_64_v2.RMASK; }
+ @Override public RegMask outregmap() { return x86_64_v2.WMASK; }
@Override public RegMask killmap() { return x86_64_v2.FLAGS_MASK; }
@Override public void encoding( Encoding enc ) {
diff --git a/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/SetX86.java b/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/SetX86.java
index 864c1b55..bcc4880b 100644
--- a/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/SetX86.java
+++ b/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/SetX86.java
@@ -4,7 +4,6 @@ import com.seaofnodes.isa.X86;
import com.seaofnodes.simple.codegen.*;
import com.seaofnodes.simple.node.*;
import com.seaofnodes.simple.util.SB;
-import com.seaofnodes.simple.util.Utils;
// Corresponds to the x86 instruction "sete && setne".
// Use result of comparison without jump.
@@ -25,15 +24,14 @@ public class SetX86 extends MachConcreteNode implements MachNode {
@Override public RegMask outregmap() { return x86_64_v2.WMASK; }
@Override public void encoding( Encoding enc ) {
- int op=switch (_bop) {
- case "==" -> 0x94; // SETE
- case "<" -> _unsigned ? 0x92 : 0x9C; // SETB /SETL
- case "<=" -> _unsigned ? 0x96 : 0x9E; // SETBE/SETLE
- case ">=" -> _unsigned ? 0x93 : 0x9D; // SETAE/SETGE
- case ">" -> _unsigned ? 0x97 : 0x9F; // SETA /SETG
- default -> throw Utils.TODO();
- };
- X86.set(enc,op&15,enc.reg(this));
+ int dst=enc.reg(this);
+ if( in(1) instanceof CmpFX86 ) {
+ // MOV preserves comparison flags; unordered is true only for !=.
+ X86.unary(enc,0xC7,0,dst);
+ enc.add4(_bop.equals("!=") ? 1 : 0);
+ enc.add1(0x7A).add1(dst>=4 ? 8 : 6); // JP past SETcc/MOVZX
+ }
+ X86.set(enc,x86_64_v2.jumpop(_bop,_unsigned)&15,dst);
}
@Override public void asm(CodeGen code, SB sb) {
diff --git a/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/x86_64_v2.java b/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/x86_64_v2.java
index 3d71b4ea..aed8ee4c 100644
--- a/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/x86_64_v2.java
+++ b/src/main/java/com/seaofnodes/simple/node/cpus/x86_64_v2/x86_64_v2.java
@@ -87,6 +87,18 @@ public class x86_64_v2 extends Machine {
default -> throw new IllegalArgumentException("Too many arguments");
};
}
+ static int jumpop(String op, boolean unsigned) {
+ if( !unsigned ) return jumpop(op);
+ return switch( op ) {
+ case "==" -> 0x84;
+ case "!=" -> 0x85;
+ case "<" -> 0x82;
+ case "<=" -> 0x86;
+ case ">" -> 0x87;
+ case ">=" -> 0x83;
+ default -> throw new IllegalArgumentException(op);
+ };
+ }
// Limit of float args passed in registers
static RegMask[] XMMS8 = new RegMask[]{
@@ -370,8 +382,9 @@ public class x86_64_v2 extends Machine {
private Node _lea(Node add, Node base, Node idx, int off) {
int scale = 0;
- if( base instanceof ShlNode && !(idx instanceof ShlNode) )
- throw Utils.TODO(); // Bug in canonicalization, should on RHS
+ if( base instanceof ShlNode && !(idx instanceof ShlNode) ) {
+ Node tmp = base; base = idx; idx = tmp;
+ }
if( idx instanceof ShlNode shift && shift.in(2) instanceof ConstantNode shfcon &&
shfcon._con instanceof TypeInteger tscale && 0 <= tscale.value() && tscale.value() <= 3 ) {
idx = shift.in(1);
diff --git a/src/main/java/com/seaofnodes/simple/parser/Expressions.java b/src/main/java/com/seaofnodes/simple/parser/Expressions.java
index 247d974e..6486aba8 100644
--- a/src/main/java/com/seaofnodes/simple/parser/Expressions.java
+++ b/src/main/java/com/seaofnodes/simple/parser/Expressions.java
@@ -400,7 +400,7 @@ final class Expressions {
* Parse integer literal
*
* <pre>
- * integerLiteral: [1-9][0-9]* | [0]
+ * integerLiteral: [1-9][0-9]* | 0[0-7]* | 0[xX][0-9a-fA-F]+
* floatLiteral: [digits].[digits]?[e [digits]]?[f]?
* </pre>
*/
diff --git a/src/main/java/com/seaofnodes/simple/parser/Lexer.java b/src/main/java/com/seaofnodes/simple/parser/Lexer.java
index 5e4b7311..62dd054e 100644
--- a/src/main/java/com/seaofnodes/simple/parser/Lexer.java
+++ b/src/main/java/com/seaofnodes/simple/parser/Lexer.java
@@ -239,6 +239,7 @@ public class Lexer {
// Return a constant Type, either TypeInteger or TypeFloat
Type parseNumber() {
Lexer start=new Lexer(this);
+ if( isHex() ) return parseInteger(parseNumberString(),16,2,start);
int old = _position;
int len = isLongOrDouble();
boolean f32 = peek()=='f';
@@ -250,7 +251,7 @@ public class Lexer {
return TypeFloat.constant(Float.parseFloat(literal));
if( len > 0 ) {
if( len > 1 && _input[old]=='0' )
- throw Parser.error("Syntax error: integer values cannot start with '0'",start);
+ return parseInteger(literal,8,0,start);
long i = Long.parseLong(literal);
return TypeInteger.constant(i);
}
@@ -262,6 +263,11 @@ public class Lexer {
}
private String parseNumberString() {
int old = _position;
+ if( isHex() ) {
+ _position += 2;
+ while( isIdLetter(peek()) ) inc();
+ return new String(_input,old,_position-old);
+ }
int len = Math.abs(isLongOrDouble());
if( peek()=='f' ) {
nextChar();
@@ -270,6 +276,24 @@ public class Lexer {
return new String(_input,old,len);
}
+ private boolean isHex() {
+ return peek()=='0' && _position+1<_input.length && (peek(1)=='x' || peek(1)=='X');
+ }
+
+ private TypeInteger parseInteger(String literal, int radix, int prefix, Lexer start) {
+ String kind = radix==16 ? "Hexadecimal" : "Octal";
+ if( literal.length()==prefix )
+ throw Parser.error(kind+" integer literal requires at least one digit",start);
+ for( int i=prefix; i<literal.length(); i++ )
+ if( Character.digit(literal.charAt(i),radix)<0 )
+ throw Parser.error("Invalid "+kind.toLowerCase()+" integer literal '"+literal+"'",start);
+ try {
+ return TypeInteger.constant(Long.parseUnsignedLong(literal.substring(prefix),radix));
+ } catch( NumberFormatException ex ) {
+ throw Parser.error(kind+" integer literal exceeds 64 bits",start);
+ }
+ }
+
// Return +len that ends a long
// Return -len that ends a double
private int isLongOrDouble() {
@@ -381,14 +405,19 @@ public class Lexer {
precision = formatNumber(site);
}
char kind=nextChar();
- if( "diuoxXfFeEgGaAcsb".indexOf(kind)<0 )
+ if( "diuoxXfFeEgGaAcsbt".indexOf(kind)<0 )
throw Parser.error("Expected a string format: %(expression), %d(expression), or %%",site);
boolean number="diuoxXfFeEgGaA".indexOf(kind)>=0;
if( !number && (flags&~1)!=0 || "uoxX".indexOf(kind)>=0 && (flags&6)!=0 ||
"diu".indexOf(kind)>=0 && (flags&8)!=0 )
throw Parser.error("Invalid flags for %"+kind,site);
- if( precision>=0 && (kind=='c' || kind=='b') )
+ if( precision>=0 && (kind=='c' || kind=='b' || kind=='t') )
throw Parser.error("Precision is not supported for %"+kind,site);
+ if( kind=='t' && peek()!='(' ) {
+ int style="FrRMDT".indexOf(nextChar());
+ if( style<0 ) throw Parser.error("Expected a time format: %t, %tF, %tr, %tR, %tM, %tD, or %tT",site);
+ flags |= style<<8;
+ }
if( peek()!='(' ) throw Parser.error("Expected '(' after string format %"+kind,site);
inc();
return new Format(kind,flags,width,precision,site);
diff --git a/src/main/java/com/seaofnodes/simple/parser/README.md b/src/main/java/com/seaofnodes/simple/parser/README.md
index cee1d64a..d0220761 100644
--- a/src/main/java/com/seaofnodes/simple/parser/README.md
+++ b/src/main/java/com/seaofnodes/simple/parser/README.md
@@ -2,7 +2,7 @@
The parser builds Sea-of-Nodes IR directly. Each component shares the cursor,
active scope, and compilation state through its `Parser P` reference; there is no intermediate
-AST. The [grammar map](../../../../../../../docs/chapter30/30-grammar.md) connects language
+AST. The [grammar map](../../../../../../../docs/chapter31/31-grammar.md) connects language
productions to their entry points.
| File | Responsibility |
diff --git a/src/main/java/com/seaofnodes/simple/parser/Strings.java b/src/main/java/com/seaofnodes/simple/parser/Strings.java
index 5bf76585..be913371 100644
--- a/src/main/java/com/seaofnodes/simple/parser/Strings.java
+++ b/src/main/java/com/seaofnodes/simple/parser/Strings.java
@@ -32,7 +32,7 @@ final class Strings {
Node strings=P.references.member(text,"string",fmt.site()).keep();
fptr=new FormatNode(value,ints.unkeep(),floats.unkeep(),strings.unkeep(),fmt.site()).peephole();
} else {
- String family="diuoxXcb".indexOf(fmt.kind())>=0 ? "integer" : fmt.kind()=='s' ? "string" : "floating";
+ String family="diuoxXcb".indexOf(fmt.kind())>=0 ? "integer" : fmt.kind()=='s' ? "string" : fmt.kind()=='t' ? "time" : "floating";
fptr=P.references.member(text,family,fmt.site());
}
call(text,fptr,fmt.site(),builder,value.unkeep(),P.con(fmt.kind() | fmt.flags()<<8),
diff --git a/src/main/java/com/seaofnodes/simple/parser/package-info.java b/src/main/java/com/seaofnodes/simple/parser/package-info.java
index c886f72a..73ebec2e 100644
--- a/src/main/java/com/seaofnodes/simple/parser/package-info.java
+++ b/src/main/java/com/seaofnodes/simple/parser/package-info.java
@@ -7,7 +7,7 @@
* use-side name/field resolution; Allocations, ControlFlow, and Conversions
* contain their shared IR construction. Lexer also supplies location snapshots.
*
- * <p>See {@code chapter30/docs/30-grammar.md} for the production-to-method map.
+ * <p>See {@code chapter31/docs/31-grammar.md} for the production-to-method map.
* Evaluation order and scope lifetimes are explicit: loop updates and declared
* constructor bodies are the two deferred source fragments. There is no AST.
*/
diff --git a/src/main/java/com/seaofnodes/simple/print/ASMPrinter.java b/src/main/java/com/seaofnodes/simple/print/ASMPrinter.java
index e8218afd..bdd887e6 100644
--- a/src/main/java/com/seaofnodes/simple/print/ASMPrinter.java
+++ b/src/main/java/com/seaofnodes/simple/print/ASMPrinter.java
@@ -10,7 +10,7 @@ import com.seaofnodes.simple.type.*;
import java.util.ArrayList;
import com.seaofnodes.simple.codegen.Encoding;
import com.seaofnodes.simple.util.BAOS;
-import java.util.HashMap;
+import java.util.Map;
/** Machine-specific facts; traversal, columns and byte display live in print/. */
public final class ASMPrinter extends AssemblyAdapter<Node> {
@@ -93,7 +93,7 @@ public final class ASMPrinter extends AssemblyAdapter<Node> {
return data;
}
// Retain this diagnostic entry point for checking that pool printing never lays out types.
- static int printConstantPool(int address, SB sb, BAOS bits, HashMap<Node,Encoding.Relo> relos,
+ static int printConstantPool(int address, SB sb, BAOS bits, Map<Node,Encoding.Relo> relos,
boolean ro, String name) {
sb.p(com.seaofnodes.print.ASMPrinter.pool(new Pool(name,bits.buf(),0,address,entries(relos.values(),ro))));
return address+bits.size();
diff --git a/src/main/java/com/seaofnodes/simple/type/TypeMem.java b/src/main/java/com/seaofnodes/simple/type/TypeMem.java
index 5faa7107..1c83ddd7 100644
--- a/src/main/java/com/seaofnodes/simple/type/TypeMem.java
+++ b/src/main/java/com/seaofnodes/simple/type/TypeMem.java
@@ -211,14 +211,19 @@ public class TypeMem extends Type {
+ (!_one ? 0 : (_clz ? 4 : 8)));
if( _alias!=1 ) {
// generic alias
- assert _alias <= 255;
- baos.packed1( _alias );
+ // Zero is reserved for alias-set flags, never a memory slice.
+ // Use it as an escape for large libraries, retaining the existing
+ // single-byte encoding (including 255) for older objects.
+ assert _alias>1;
+ baos.packed1(_alias<=255 ? _alias : 0);
+ if( _alias>255 ) baos.packed4(_alias);
}
XInt.packed(baos,_escFs);
XInt.packed(baos,_escAs);
}
static TypeMem packed( int tag, BAOS bais ) {
int alias = (tag&1)==0 ? 1 : bais.packed1();
+ if( alias==0 ) alias=bais.packed4();
boolean xfinal= (tag&2)==2;
boolean one = (tag&12)!=0;
boolean clz = (tag&4)==4;
diff --git a/src/main/smp/sys.smp b/src/main/smp/sys.smp
index 1201315b..296635ae 100644
--- a/src/main/smp/sys.smp
+++ b/src/main/smp/sys.smp
@@ -2,12 +2,16 @@
// top-level default standard library import
val adt = adt; // Abstract Data Types
+val audio = audio; // Optional background sound (miniaudio)
val ary = ary; // Array utilities
val aryi64 = aryi64; // Variable sized array of i64
val aryu8 = aryu8; // Variable sized array of u8
val char = char; // Character utilities
val io = io; // IO library
+val gfx = gfx; // Optional pixel window (MiniFB)
+val draw = draw; // Software raster drawing in Simple
val text = text; // Typed text conversions
+val time = time; // Clocks, calendar text, and cooperative timers
val libc = libc; // libc FFI bindings
val rt = rt; // Run Time support
val scan = scan; // A simple string parser
diff --git a/src/main/smp/sys/audio.smp b/src/main/smp/sys/audio.smp
new file mode 100644
index 00000000..89e6dfa9
--- /dev/null
+++ b/src/main/smp/sys/audio.smp
@@ -0,0 +1,29 @@
+// Optional background audio. All API calls belong on the game/main thread.
+int OK = 0;
+int ERROR = -1;
+int BOOP = 1;
+int WIN = 2;
+int LOSE = 3;
+int RATE = 48000;
+{ i32 } simple_audio_open = "C";
+{ i64 u32 -> i32 } simple_audio_load = "C";
+{ i32 -> i32 } simple_audio_play = "C";
+{ i32 } simple_audio_stop = "C";
+{ i32 } simple_audio_playing = "C";
+{ i32 } simple_audio_close = "C";
+// Missing/disabled backend returns ERROR quietly; the game may remain silent.
+val open = { -> simple_audio_open(); };
+// Mono signed 16-bit PCM at RATE, up to ten seconds. Copies into native memory.
+// Load at startup: up to 28 custom handles, valid until close, then reload.
+val load = { i16[~] samples ->
+ i64 ptr = samples;
+ simple_audio_load(ptr,samples#);
+};
+// Starts/restarts immediately, replacing the previous sound; never waits for it.
+val play = { int sound -> simple_audio_play(sound); };
+val boop = { -> simple_audio_play(BOOP); };
+val win = { -> simple_audio_play(WIN); };
+val lose = { -> simple_audio_play(LOSE); };
+val stop = { -> simple_audio_stop(); };
+val playing = { -> simple_audio_playing(); };
+val close = { -> simple_audio_close(); };
diff --git a/src/main/smp/sys/draw.smp b/src/main/smp/sys/draw.smp
new file mode 100644
index 00000000..0e4a27d4
--- /dev/null
+++ b/src/main/smp/sys/draw.smp
@@ -0,0 +1,18 @@
+// Software raster drawing. No window or native calls; pixels are packed RGB (0x00RRGGBB).
+u32 BLACK = 0x000000;
+u32 WHITE = 0xffffff;
+u32 RED = 0xe05252;
+u32 GREEN = 0x48b88c;
+u32 BLUE = 0x558ee6;
+
+// Components are clamped to bytes rather than wrapping.
+val rgb = { int r, int g, int b ->
+ r = r<0 ? 0 : r>255 ? 255 : r;
+ g = g<0 ? 0 : g>255 ? 255 : g;
+ b = b<0 ? 0 : b>255 ? 255 : b;
+ u32 color = (r<<16) | (g<<8) | b;
+ return color;
+};
+
+val Canvas = Canvas; // Pixel canvas and drawing methods
+val Button = Button; // Pointer state and rendering for a rectangular button
diff --git a/src/main/smp/sys/draw/Button.smp b/src/main/smp/sys/draw/Button.smp
new file mode 100644
index 00000000..edf01119
--- /dev/null
+++ b/src/main/smp/sys/draw/Button.smp
@@ -0,0 +1,41 @@
+// Feed pointer state once per event-loop iteration. Activate on release inside,
+// only after a press that began inside. No native/window dependency.
+struct Button {
+ int x, y, width, height;
+ u8[~] !label;
+ bool !enabled=true, !hovered=false, !pressed=false;
+ bool !_held=false, !_armed=false;
+ new Button = { int px, int py, int w, int h, u8[~] text ->
+ x=px; y=py; width=w; height=h; label=text;
+ };
+ val contains = { i64 px, i64 py ->
+ if( width<=0 || height<=0 ) return false;
+ i64 right=x, bottom=y; right+=width; bottom+=height;
+ if( px<x || py<y || px>=right || py>=bottom ) return false;
+ return true;
+ };
+ val update = { i64 px, i64 py, bool down ->
+ hovered=false;
+ if( enabled ) hovered=contains(px,py);
+ if( !enabled ) _armed=false;
+ if( down && !_held ) _armed=hovered;
+ var clicked=false;
+ if( !down ) if( _held ) if( _armed ) if( hovered ) clicked=true;
+ if( !down ) _armed=false;
+ _held=down;
+ pressed=false;
+ if( down ) if( _armed ) if( hovered ) pressed=true;
+ return clicked;
+ };
+ val draw = { !sys.draw.Canvas screen, int scale, u32 foreground, u32 background, u32 active ->
+ screen.fillRect(x,y,width,height,enabled && (hovered || pressed) ? active : background);
+ screen.rect(x,y,width,height,2,enabled ? foreground : active);
+ if( scale>0 ) {
+ i64 textWidth=label#; textWidth=(textWidth*4-1)*scale;
+ if( textWidth<=width-8 && 5*scale<=height-8 )
+ screen.text(x+(width-textWidth)/2,y+(height-5*scale)/2+(pressed ? 1 : 0),
+ label,scale,enabled ? foreground : active);
+ }
+ return self;
+ };
+};
diff --git a/src/main/smp/sys/draw/Canvas.smp b/src/main/smp/sys/draw/Canvas.smp
new file mode 100644
index 00000000..8c01b4b3
--- /dev/null
+++ b/src/main/smp/sys/draw/Canvas.smp
@@ -0,0 +1,176 @@
+struct Canvas {
+ u32 width;
+ u32 height;
+ u32[] pixels;
+
+ new Canvas = { int w, int h ->
+ i64 count = w;
+ count *= h;
+ if( w<=0 || h<=0 || count>2147483647 ) libc._exit(1);
+ width = w;
+ height = h;
+ pixels = new u32[count];
+ };
+
+ val clear = { u32 color ->
+ for( int i=0; i<pixels#; i++ ) pixels[i] = color;
+ return self;
+ };
+
+ val pixel = { i64 x, i64 y, u32 color ->
+ if( 0<=x<width && 0<=y<height ) pixels[y*width+x] = color;
+ return self;
+ };
+
+ // Integer coordinates, top-left origin. Rectangle extents are half-open.
+ val fillRect = { i64 x, i64 y, i64 w, i64 h, u32 color ->
+ if( w<=0 || h<=0 ) return self;
+ i64 right = x; right += w;
+ i64 bottom = y; bottom += h;
+ if( right<=0 || bottom<=0 || x>=width || y>=height ) return self;
+ int x0 = x<0 ? 0 : x, y0 = y<0 ? 0 : y;
+ int x1 = right>width ? width : right, y1 = bottom>height ? height : bottom;
+ for( int py=y0; py<y1; py++ )
+ for( int px=x0; px<x1; px++ ) pixels[py*width+px] = color;
+ return self;
+ };
+
+ // Outline stays inside the rectangle. Wide borders become a filled rect.
+ val rect = { i64 x, i64 y, i64 w, i64 h, i64 thickness, u32 color ->
+ if( w<=0 || h<=0 || thickness<=0 ) return self;
+ if( thickness>=w || thickness>=h || thickness>=w-thickness || thickness>=h-thickness )
+ return fillRect(x,y,w,h,color);
+ fillRect(x,y,w,thickness,color);
+ fillRect(x,y+h-thickness,w,thickness,color);
+ fillRect(x,y+thickness,thickness,h-2*thickness,color);
+ return fillRect(x+w-thickness,y+thickness,thickness,h-2*thickness,color);
+ };
+
+ // Integer centers are pixel centers. Circle outlines extend inward.
+ val fillCircle = { i64 cx, i64 cy, int radius, u32 color ->
+ return _circle(cx,cy,radius,0,true,color);
+ };
+ val circle = { i64 cx, i64 cy, int radius, int thickness, u32 color ->
+ return _circle(cx,cy,radius,thickness,false,color);
+ };
+ val _circle = { i64 cx, i64 cy, int radius, int thickness, bool filled, u32 color ->
+ if( radius<0 || (!filled && thickness<=0) ) return self;
+ i64 left = cx; left -= radius;
+ i64 top = cy; top -= radius;
+ i64 right = cx; right += radius;
+ i64 bottom = cy; bottom += radius;
+ if( right<0 || bottom<0 || left>=width || top>=height ) return self;
+ int x0 = left<0 ? 0 : left, y0 = top<0 ? 0 : top;
+ int x1 = right>=width ? width-1 : right, y1 = bottom>=height ? height-1 : bottom;
+ i64 outer2 = radius;
+ outer2 *= radius;
+ i64 inner = filled ? -1 : radius-thickness;
+ for( int py=y0; py<=y1; py++ )
+ for( int px=x0; px<=x1; px++ ) {
+ i64 dx = px; dx -= cx;
+ i64 dy = py; dy -= cy;
+ i64 d2 = dx*dx+dy*dy;
+ if( d2<=outer2 && (inner<=0 || d2>inner*inner) ) pixels[py*width+px] = color;
+ }
+ return self;
+ };
+
+ // Round caps; a zero-length segment is a dot. Only the clipped bounding
+ // box is scanned. Floating distances avoid overflowing coordinate squares.
+ val line = { i64 x0, i64 y0, i64 x1, i64 y1, i64 thickness, u32 color ->
+ if( thickness<=0 ) return self;
+ i64 pad = thickness/2+1;
+ i64 left = x0<x1 ? x0 : x1; left -= pad;
+ i64 top = y0<y1 ? y0 : y1; top -= pad;
+ i64 right = x0>x1 ? x0 : x1; right += pad;
+ i64 bottom = y0>y1 ? y0 : y1; bottom += pad;
+ if( right<0 || bottom<0 || left>=width || top>=height ) return self;
+ int bx0 = left<0 ? 0 : left, by0 = top<0 ? 0 : top;
+ int bx1 = right>=width ? width-1 : right, by1 = bottom>=height ? height-1 : bottom;
+ for( int py=by0; py<=by1; py++ )
+ for( int px=bx0; px<=bx1; px++ )
+ if( _onLine(px,py,x0,y0,x1,y1,thickness) ) pixels[py*width+px] = color;
+ return self;
+ };
+
+ // Small 3x5 bitmap font for game labels. Lowercase maps to uppercase.
+ // Newline returns to x; unsupported characters draw a question mark.
+ val text = { i64 x, i64 y, u8[~] str, int scale, u32 color ->
+ if( scale<=0 ) return self;
+ i64 px = x, py = y;
+ for( int i=0; i<str#; i++ ) {
+ u8 c = str[i];
+ if( c=='\n' ) { px=x; py+=6*scale; continue; }
+ int bits = _glyph(c);
+ for( int row=0; row<5; row++ )
+ for( int col=0; col<3; col++ )
+ if( (bits >> (14-row*3-col)) & 1 )
+ fillRect(px+col*scale,py+row*scale,scale,scale,color);
+ px += 4*scale;
+ }
+ return self;
+ };
+};
+
+val _glyph = { u8 c ->
+ // Five octal digits: one 3-bit row per digit, top to bottom.
+ if( 'a'<=c<='z' ) c -= 'a'-'A';
+ if( c==' ' ) return 000000;
+ if( c=='0' ) return 075557;
+ if( c=='1' ) return 026227;
+ if( c=='2' ) return 061247;
+ if( c=='3' ) return 061216;
+ if( c=='4' ) return 055711;
+ if( c=='5' ) return 074616;
+ if( c=='6' ) return 034757;
+ if( c=='7' ) return 071222;
+ if( c=='8' ) return 075757;
+ if( c=='9' ) return 075716;
+ if( c=='A' ) return 025755;
+ if( c=='B' ) return 065656;
+ if( c=='C' ) return 034443;
+ if( c=='D' ) return 065556;
+ if( c=='E' ) return 074647;
+ if( c=='F' ) return 074644;
+ if( c=='G' ) return 034553;
+ if( c=='H' ) return 055755;
+ if( c=='I' ) return 072227;
+ if( c=='J' ) return 011152;
+ if( c=='K' ) return 055655;
+ if( c=='L' ) return 044447;
+ if( c=='M' ) return 057755;
+ if( c=='N' ) return 057775;
+ if( c=='O' ) return 025552;
+ if( c=='P' ) return 065644;
+ if( c=='Q' ) return 025573;
+ if( c=='R' ) return 065655;
+ if( c=='S' ) return 034216;
+ if( c=='T' ) return 072222;
+ if( c=='U' ) return 055557;
+ if( c=='V' ) return 055552;
+ if( c=='W' ) return 055775;
+ if( c=='X' ) return 055255;
+ if( c=='Y' ) return 055222;
+ if( c=='Z' ) return 071247;
+ if( c=='-' ) return 000700;
+ if( c=='.' ) return 000002;
+ if( c==':' ) return 002020;
+ if( c=='!' ) return 022202;
+ if( c=='?' ) return 061202;
+ return 061202;
+};
+
+// Pixel coverage for a round-ended segment, separate from traversal.
+val _onLine = { i64 px, i64 py, i64 x0, i64 y0, i64 x1, i64 y1, i64 thickness ->
+ f64 dx = x1; dx -= x0;
+ f64 dy = y1; dy -= y0;
+ f64 vx = px; vx -= x0;
+ f64 vy = py; vy -= y0;
+ f64 length2 = dx*dx+dy*dy;
+ bool point = x0==x1 && y0==y1;
+ f64 t = point ? 0.0 : (vx*dx+vy*dy)/length2;
+ t = t<0 ? 0.0 : t>1 ? 1.0 : t;
+ f64 ex = vx-t*dx, ey = vy-t*dy;
+ f64 half = thickness/2.0;
+ return ex*ex+ey*ey<=half*half;
+};
diff --git a/src/main/smp/sys/gfx.smp b/src/main/smp/sys/gfx.smp
new file mode 100644
index 00000000..cde2cc0d
--- /dev/null
+++ b/src/main/smp/sys/gfx.smp
@@ -0,0 +1,42 @@
+// Minimal pixel-window interface; drawing belongs in Simple.
+// One fixed-size window, main thread only. Pixel format is 0x00RRGGBB.
+// open/present/poll return OK, CLOSED (normal user close/Esc), or ERROR.
+int OK = 0;
+int CLOSED = 1;
+int ERROR = -1;
+int LEFT = 1;
+int ESCAPE = 256;
+
+{ i64 u32 u32 -> i32 } simple_gfx_open = "C";
+{ i64 u32 -> i32 } simple_gfx_present = "C";
+{ i32 -> i32 } simple_gfx_poll = "C";
+{ i32 } simple_gfx_mouse_x = "C";
+{ i32 } simple_gfx_mouse_y = "C";
+{ i32 -> i32 } simple_gfx_button = "C";
+{ i32 -> i32 } simple_gfx_key = "C";
+{ i32 } simple_gfx_close = "C";
+{ i64 -> i32 } simple_gfx_wait = "C";
+
+// Failure prints a diagnostic to stderr and returns ERROR; callers decide
+// whether to exit or fall back to text. No native library is loaded until here.
+val open = { u8[~] title, u32 width, u32 height ->
+ i64 ptr = aryu8.str0(title);
+ simple_gfx_open(ptr,width,height);
+};
+// Array length must be width*height. The native layer copies for OS repaints;
+// it never keeps a pointer into the Simple heap after this call returns.
+val present = { u32[~] pixels ->
+ i64 ptr = pixels;
+ simple_gfx_present(ptr,pixels#);
+};
+// wait=true waits up to 17ms, waking early for input to avoid a busy loop.
+val poll = { bool wait -> simple_gfx_poll(wait); };
+val mouseX = { -> simple_gfx_mouse_x(); };
+val mouseY = { -> simple_gfx_mouse_y(); };
+val button = { int button -> simple_gfx_button(button); };
+// Printable keys use uppercase ASCII; special keys use MiniFB's key codes.
+val key = { int key -> simple_gfx_key(key); };
+val close = { -> simple_gfx_close(); };
+// Wait for input or at most timeout milliseconds; 0 polls, -1 waits indefinitely.
+// This blocks the game thread, dispatches window events, and may wake early.
+val wait = { i64 timeout -> simple_gfx_wait(timeout); };
diff --git a/src/main/smp/sys/text.smp b/src/main/smp/sys/text.smp
index a414ff45..a28edf6d 100644
--- a/src/main/smp/sys/text.smp
+++ b/src/main/smp/sys/text.smp
@@ -96,3 +96,8 @@ val f64 = { f64 x -> end(floating(begin(),x, 0 ,0,-1)); };
val char = { i64 x -> end(integer (begin(),x,'c',0,-1)); };
val bool = { bool x -> end(integer (begin(),x,'b',0,-1)); };
val hex = { i64 x -> end(integer (begin(),x,'x',0,-1)); };
+
+// Time selectors occupy spec bits 16..23; padding shares string formatting.
+val time = { !aryu8 b, i64 value, i64 spec, i64 width, i64 precision ->
+ string(b,sys.time.format(value,(spec>>>16)&255),spec,width,-1);
+};
diff --git a/src/main/smp/sys/time.smp b/src/main/smp/sys/time.smp
new file mode 100644
index 00000000..cdcb5055
--- /dev/null
+++ b/src/main/smp/sys/time.smp
@@ -0,0 +1,191 @@
+// Instants: bare i64 UTC Unix-epoch milliseconds. Durations: i64 milliseconds.
+// Monotonic readings are a separate clock domain, for deadlines/elapsed time.
+val DateTime = DateTime;
+val Timers = Timers;
+i64 SECOND=1000, MINUTE=60000, HOUR=3600000, DAY=86400000;
+i64 MIN=-62135596800000, MAX=253402300799999; // Calendar years 1..9999.
+i64 LIMIT=9223372036854775807;
+int ISO=0, CLOCK12=1, CLOCK24=2, ELAPSED=3, DATE=4, CLOCK24_SECONDS=5;
+{ i64 } simple_time_now = "C";
+{ i64 } simple_time_mono = "C";
+{ i64 } simple_time_nano = "C";
+{ i64 i32 -> i32 } simple_time_sleep = "C";
+{ i64 i64 -> i32 } simple_time_local = "C";
+{ i64 i64 u32 -> i32 } simple_time_zone = "C";
+val now = { -> simple_time_now(); };
+val monotonic = { -> simple_time_mono(); };
+val nanoTime = { -> simple_time_nano(); };
+val sleep = { i64 ms -> simple_time_sleep(ms,0); };
+val sleepUntil = { i64 deadline -> simple_time_sleep(deadline,1); };
+
+// Signed duration conversions. Overflow returns -1; reverse conversions truncate
+// toward zero. An exact duration day is 24 hours, independent of DST.
+val _scale = { i64 n, i64 unit ->
+ if( n>LIMIT/unit || n<(-LIMIT-1)/unit ) return -1;
+ return n*unit;
+};
+val seconds = { i64 n -> _scale(n,SECOND); };
+val minutes = { i64 n -> _scale(n,MINUTE); };
+val hours = { i64 n -> _scale(n,HOUR); };
+val days = { i64 n -> _scale(n,DAY); };
+val toSeconds = { i64 ms -> ms/SECOND; };
+val toMinutes = { i64 ms -> ms/MINUTE; };
+val toHours = { i64 ms -> ms/HOUR; };
+val toDays = { i64 ms -> ms/DAY; };
+val fromNanos = { i64 ns -> ns/1000000; };
+val toNanos = { i64 ms -> _scale(ms,1000000); };
+// Clamp to zero once due, and saturate for widely separated i64 readings.
+val remaining = { i64 deadline, i64 current ->
+ if( deadline<=current ) return 0;
+ if( current<0 && deadline>LIMIT+current ) return LIMIT;
+ return deadline-current;
+};
+val _mod = { i64 n, i64 unit -> n-n/unit*unit; };
+val leap = { int year -> _mod(year,4)==0 && (_mod(year,100)!=0 || _mod(year,400)==0); };
+val monthDays = { int year, int month ->
+ if( month<1 || month>12 ) return 0;
+ return month==2 ? (leap(year) ? 29 : 28) :
+ month==4 || month==6 || month==9 || month==11 ? 30 : 31;
+};
+
+// Gregorian civil dates, expressed using 400-year eras; no per-year loops.
+val _days = { i64 year, i64 month, i64 day ->
+ year -= month<=2 ? 1 : 0;
+ i64 era=year/400, y=year-era*400;
+ i64 mp=month+(month>2 ? -3 : 9);
+ return era*146097+y*365+y/4-y/100+(153*mp+2)/5+day-1-719468;
+};
+val valid = { int year, int month, int day, int hour, int minute, int second, int millisecond ->
+ return year>=1 && year<=9999 && month>=1 && month<=12 && day>=1 && day<=monthDays(year,month) &&
+ hour>=0 && hour<=23 && minute>=0 && minute<=59 && second>=0 && second<=59 &&
+ millisecond>=0 && millisecond<=999;
+};
+val makeUTC = { int year, int month, int day, int hour, int minute, int second, int millisecond ->
+ if( !valid(year,month,day,hour,minute,second,millisecond) ) return -1;
+ return _days(year,month,day)*DAY+hour*HOUR+minute*MINUTE+second*SECOND+millisecond;
+};
+val utc = { i64 instant ->
+ val d=DateTime.empty();
+ if( instant<MIN || instant>MAX ) return d;
+ i64 day=instant/DAY, rest=_mod(instant,DAY);
+ if( rest<0 ) { rest+=DAY; day--; }
+ i64 z=day+719468, era=z/146097, doe=z-era*146097;
+ i64 y=(doe-doe/1460+doe/36524-doe/146096)/365;
+ i64 doy=doe-(365*y+y/4-y/100), mp=(5*doy+2)/153;
+ d.day=doy-(153*mp+2)/5+1;
+ d.month=mp+(mp<10 ? 3 : -9);
+ d.year=y+era*400+(d.month<=2 ? 1 : 0);
+ d.hour=rest/HOUR; d.minute=_mod(rest/MINUTE,60);
+ d.second=_mod(rest/SECOND,60); d.millisecond=_mod(rest,SECOND);
+ d.weekday=_mod(day+4,7);
+ if( d.weekday<0 ) d.weekday+=7;
+ return d;
+};
+val local = { i64 instant ->
+ val d=DateTime.empty();
+ val f=new i64[10];
+ i64 ptr=f;
+ if( simple_time_local(instant,ptr)!=0 ) return d;
+ d.year=f[0]; d.month=f[1]; d.day=f[2];
+ d.hour=f[3]; d.minute=f[4]; d.second=f[5]; d.millisecond=f[6];
+ d.weekday=f[7]; d.offsetSeconds=f[8]; d.daylight=f[9]!=0;
+ return d;
+};
+val zoneName = { i64 instant ->
+ val bytes=new u8[128];
+ i64 ptr=bytes;
+ int n=simple_time_zone(instant,ptr,bytes#);
+ if( n<0 ) return "";
+ val b=new aryu8();
+ for( int i=0; i<n; i++ ) b.add(bytes[i]);
+ return text.end(b);
+};
+
+val _digits = { !aryu8 b, i64 n, int width ->
+ text.integer(b,n,'d'|4096,width,-1);
+};
+val _format = { i64 instant, int style, bool localZone ->
+ val d=localZone ? local(instant) : utc(instant);
+ if( d.year==0 || style<0 || style>5 || style==3 ) return "<invalid time>";
+ val b=new aryu8();
+ if( style==4 ) {
+ _digits(b,d.day,2).add('/'); _digits(b,d.month,2).add('/'); _digits(b,_mod(d.year,100),2);
+ } else {
+ if( style==0 ) {
+ _digits(b,d.year,4).add('-'); _digits(b,d.month,2).add('-'); _digits(b,d.day,2).add('T');
+ }
+ int hour=d.hour;
+ if( style==1 ) { hour=_mod(hour,12); if( hour==0 ) hour=12; }
+ _digits(b,hour,style==1 ? 1 : 2).add(':'); _digits(b,d.minute,2);
+ if( style==0 || style==5 ) { b.add(':'); _digits(b,d.second,2); }
+ if( style==1 ) b.addAry(d.hour<12 ? "am" : "pm");
+ if( style==0 ) {
+ b.add('.'); _digits(b,d.millisecond,3);
+ if( d.offsetSeconds==0 ) b.add('Z');
+ else {
+ i64 offset=d.offsetSeconds;
+ b.add(offset<0 ? '-' : '+');
+ if( offset<0 ) offset=-offset;
+ _digits(b,offset/3600,2).add(':'); _digits(b,_mod(offset/60,60),2);
+ if( _mod(offset,60)!=0 ) { b.add(':'); _digits(b,_mod(offset,60),2); }
+ }
+ }
+ }
+ return text.end(b);
+};
+
+val elapsed = { i64 ms ->
+ val b=new aryu8();
+ // Nonpositive arithmetic handles the minimum i64 without negating it.
+ if( ms<0 ) b.add('-'); else ms=-ms;
+ _digits(b,-(ms/MINUTE),2).add(':');
+ _digits(b,-_mod(ms/SECOND,60),2).add('.'); _digits(b,-_mod(ms,SECOND),3);
+ return text.end(b);
+};
+val format = { i64 instant, int style ->
+ if( style==ELAPSED ) return elapsed(instant);
+ return _format(instant,style,false);
+};
+val formatLocal = { i64 instant, int style ->
+ if( style==ELAPSED ) return elapsed(instant);
+ return _format(instant,style,true);
+};
+
+val _number = { u8[~] s, i64 at, int count ->
+ if( at<0 || count<1 || at+count>s# ) return -1;
+ int n=0;
+ for( int i=0; i<count; i++ ) {
+ int c=s[at+i];
+ if( c<'0' || c>'9' ) return -1;
+ n=n*10+c-'0';
+ }
+ return n;
+};
+// Keep suffix validation separate from calendar construction.
+val _parseZone_noInline = { u8[~] s, int at, i64 instant ->
+ if( at<0 || at>=s# ) return -1;
+ i64 offset=0;
+ if( s[at]=='Z' ) { if( at+1!=s# ) return -1; }
+ else {
+ int sign=s[at]=='+' ? 1 : s[at]=='-' ? -1 : 0;
+ if( !sign || (s#!=at+6 && s#!=at+9) || s[at+3]!=':' ) return -1;
+ int oh=_number(s,at+1,2), om=_number(s,at+4,2), os=0;
+ if( s#==at+9 ) { if( s[at+6]!=':' ) return -1; os=_number(s,at+7,2); }
+ if( oh<0 || oh>23 || om<0 || om>59 || os<0 || os>59 ) return -1;
+ offset=sign*(oh*HOUR+om*MINUTE+os*SECOND);
+ }
+ instant-=offset;
+ return instant<MIN || instant>MAX ? -1 : instant;
+};
+
+// Strict ISO absolute instants only. -1 is reserved for failure (and therefore
+// cannot distinguish the valid instant one millisecond before Unix epoch).
+val parse = { u8[~] s ->
+ if( s#<20 || s[4]!='-' || s[7]!='-' || s[10]!='T' || s[13]!=':' || s[16]!=':' ) return -1;
+ int y=_number(s,0,4), m=_number(s,5,2), d=_number(s,8,2);
+ int h=_number(s,11,2), min=_number(s,14,2), sec=_number(s,17,2);
+ bool fraction=s[19]=='.';
+ int ms=fraction ? _number(s,20,3) : 0;
+ if( !valid(y,m,d,h,min,sec,ms) ) return -1;
+ return _parseZone_noInline(s,fraction ? 23 : 19,makeUTC(y,m,d,h,min,sec,ms));
+};
diff --git a/src/main/smp/sys/time/DateTime.smp b/src/main/smp/sys/time/DateTime.smp
new file mode 100644
index 00000000..5d117467
--- /dev/null
+++ b/src/main/smp/sys/time/DateTime.smp
@@ -0,0 +1,11 @@
+// Calendar view of an instant. Month/day start at 1; weekday is Sunday=0.
+// year==0 means out of the supported calendar range (years 1 through 9999).
+struct DateTime {
+ int !year=0, !month=0, !day=0;
+ int !hour=0, !minute=0, !second=0, !millisecond=0;
+ int !weekday=0, !offsetSeconds=0;
+ bool !daylight=false;
+};
+
+// Factory in the defining unit, so callers need no forward instance type.
+val empty = { -> new DateTime(); };
diff --git a/src/main/smp/sys/time/Timers.smp b/src/main/smp/sys/time/Timers.smp
new file mode 100644
index 00000000..b98c96ef
--- /dev/null
+++ b/src/main/smp/sys/time/Timers.smp
@@ -0,0 +1,55 @@
+// Cooperative timers. poll returns IDs; callbacks never run on another thread.
+struct Timers {
+ i64[] !_ids=new i64[8], !_due=new i64[8], !_period=new i64[8];
+ i64 !_next=1;
+
+ val _add = { i64 current, i64 delay, i64 period ->
+ if( current<0 || delay<0 || current>time.LIMIT-delay || _next==time.LIMIT ) return -1;
+ int slot=0;
+ while( slot<_ids# && _ids[slot]!=0 ) slot++;
+ if( slot==_ids# ) {
+ if( _ids#>1073741823 ) return -1;
+ val ids=new i64[_ids#*2], due=new i64[_ids#*2], periods=new i64[_ids#*2];
+ for( int i=0; i<_ids#; i++ ) { ids[i]=_ids[i]; due[i]=_due[i]; periods[i]=_period[i]; }
+ _ids=ids; _due=due; _period=periods;
+ // Retry against the newly published arrays after growing.
+ return _add(current,delay,period);
+ }
+ i64 id=_next++;
+ _ids[slot]=id; _due[slot]=current+delay; _period[slot]=period;
+ return id;
+ };
+ // Explicit-clock versions make game logic and tests deterministic.
+ val afterAt = { i64 current, i64 delay -> _add(current,delay,0); };
+ val everyAt = { i64 current, i64 period -> period<=0 ? -1 : _add(current,period,period); };
+ val after = { i64 delay -> afterAt(time.monotonic(),delay); };
+ val every = { i64 period -> everyAt(time.monotonic(),period); };
+ val cancel = { i64 id ->
+ if( id<=0 ) return false;
+ for( int i=0; i<_ids#; i++ ) if( _ids[i]==id ) { _ids[i]=0; return true; }
+ return false;
+ };
+ val nextDeadline = { ->
+ i64 next=-1;
+ for( int i=0; i<_ids#; i++ )
+ if( _ids[i]!=0 && (next<0 || _due[i]<next) ) next=_due[i];
+ return next;
+ };
+ // Earliest deadline first; equal deadlines use registration order.
+ // Repeats skip missed ticks and retain their phase, avoiding bursts/drift.
+ val poll = { i64 current ->
+ int slot=-1;
+ for( int i=0; i<_ids#; i++ )
+ if( _ids[i]!=0 && _due[i]<=current && (slot<0 || _due[i]<_due[slot] ||
+ (_due[i]==_due[slot] && _ids[i]<_ids[slot])) ) slot=i;
+ if( slot<0 ) return -1;
+ i64 id=_ids[slot], period=_period[slot];
+ if( period==0 ) _ids[slot]=0;
+ else {
+ i64 steps=(current-_due[slot])/period;
+ if( steps>=(time.LIMIT-_due[slot])/period ) _ids[slot]=0;
+ else _due[slot]+=(steps+1)*period;
+ }
+ return id;
+ };
+};
diff --git a/src/test/c/audio_test.c b/src/test/c/audio_test.c
new file mode 100644
index 00000000..f82504c3
--- /dev/null
+++ b/src/test/c/audio_test.c
@@ -0,0 +1,48 @@
+// Explicit optional integration checks: real miniaudio thread, silent device.
+#define SIMPLE_AUDIO_TEST_NULL
+#include "../../main/c/simple_miniaudio.c"
+#include <assert.h>
+
+int main(void) {
+ assert(api.play(1)==-1 && api.playing()==0);
+ assert(api.stop()==0);
+ api.close();
+ for (int pass=0;pass<2;++pass) {
+ assert(api.open()==0 && api.open()==0);
+ assert(api.load(NULL,1)==-1);
+ int16_t pcm[RATE]={0};
+ assert(api.load(pcm,0)==-1 && api.load(pcm,RATE*10+1)==-1);
+ int sound=api.load(pcm,RATE);
+ assert(sound==4);
+ pcm[0]=12345;
+ assert(sounds[sound].pcm[0]==0);
+ assert(api.play(-1)==-1 && api.play(0)==-1 && api.play(SLOTS)==-1);
+ assert(api.play(sound)==0 && api.playing());
+ // A one-second sound returns while playback is still in progress.
+ assert(api.stop()==0 && !api.playing());
+ for (int cue=1;cue<=3;++cue) {
+ assert(api.play(cue)==0);
+ int elapsed=0;
+ while (api.playing() && elapsed<2000) { ma_sleep(10); elapsed+=10; }
+ assert(!api.playing());
+ }
+ for (int i=5;i<SLOTS;++i) assert(api.load(pcm,1)==i);
+ assert(api.load(pcm,1)==-1);
+ assert(api.play(sound)==0);
+ api.close(); // Join during playback, then reopen next pass.
+ api.close();
+ assert(api.play(sound)==-1 && !api.playing());
+ }
+ // The loss cue reverses exactly the same three softly enveloped notes.
+ assert(tone(1,0)==0 && tone(2,0)==0 && tone(3,1)==0);
+ uint32_t note=RATE*16/100;
+ for (int n=0;n<3;++n)
+ assert(memcmp(sounds[2].pcm+n*note,sounds[3].pcm+(2-n)*note,note*sizeof(int16_t))==0);
+ for (int i=1;i<=3;++i) {
+ assert(sounds[i].pcm[0]==0);
+ for (uint32_t j=0;j<sounds[i].count;++j) assert(abs(sounds[i].pcm[j])<=3200);
+ free(sounds[i].pcm); sounds[i].pcm=NULL;
+ }
+ puts("audio native checks passed");
+ return 0;
+}
diff --git a/src/test/java/com/seaofnodes/simple/AudioTest.java b/src/test/java/com/seaofnodes/simple/AudioTest.java
new file mode 100644
index 00000000..db8542d0
--- /dev/null
+++ b/src/test/java/com/seaofnodes/simple/AudioTest.java
@@ -0,0 +1,94 @@
+package com.seaofnodes.simple;
+
+import com.seaofnodes.simple.util.Ary;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+// Loader and Simple/C ABI checks: no miniaudio download or audio device.
+public class AudioTest {
+ private static String missing, playback;
+
+ @BeforeClass public static void compilePrograms() throws Exception {
+ var paths=new Ary<>(new String[]{TestC.RELEASE_SYS_DIR});
+ missing=TestC.compile("""
+ val main = { ->
+ if( sys.audio.open()!=sys.audio.ERROR ) return 1;
+ if( sys.audio.boop()!=sys.audio.ERROR || sys.audio.playing() ) return 2;
+ sys.audio.stop(); sys.audio.close(); sys.audio.close();
+ sys.io.p("silent fallback\\n");
+ return 0;
+ };
+ ""","audio_missing",paths,TestC.CALL_CONVENTION,null,null,-1);
+ playback=TestC.compile("""
+ val main = { ->
+ if( sys.audio.open()!=sys.audio.OK ) return 1;
+ val pcm = new i16[3];
+ pcm[0]=-32768; pcm[1]=123; pcm[2]=32767;
+ int sound = sys.audio.load(pcm);
+ if( sound!=4 || sys.audio.play(sound)!=0 || !sys.audio.playing() ) return 2;
+ if( sys.audio.boop()!=0 || sys.audio.win()!=0 || sys.audio.lose()!=0 ) return 3;
+ sys.audio.stop();
+ if( sys.audio.playing() ) return 4;
+ sys.audio.close();
+ return 0;
+ };
+ ""","audio_playback",paths,TestC.CALL_CONVENTION,null,null,-1);
+ }
+
+ private static String run(String exe, String backend) throws Exception {
+ ProcessBuilder pb=new ProcessBuilder(exe).redirectErrorStream(true);
+ if( backend==null ) pb.environment().remove("SIMPLE_MINIAUDIO");
+ else pb.environment().put("SIMPLE_MINIAUDIO",backend);
+ Process child=pb.start();
+ try {
+ assertTrue("audio program timed out",child.waitFor(10,TimeUnit.SECONDS));
+ String out=new String(child.getInputStream().readAllBytes(),StandardCharsets.UTF_8);
+ assertEquals(out,0,child.exitValue());
+ return out;
+ } finally { if( child.isAlive() ) child.destroyForcibly(); }
+ }
+
+ @Test public void silentFallback() throws Exception {
+ assertEquals("silent fallback\n",run(missing,null));
+ assertEquals("silent fallback\n",run(missing,"off"));
+ assertTrue(run(missing,"no-such-miniaudio-backend").contains("cannot load backend"));
+ }
+
+ @Test public void nativeBoundary() throws Exception {
+ Path source=Path.of("build/objs/audio_stub.c");
+ Path library=Path.of("build/objs/audio_stub"+(TestC.OS.startsWith("Windows") ? ".dll" : ".so"));
+ Files.writeString(source,"""
+ #include "simple_audio.h"
+ static int active;
+ static int32_t open_audio(void) { return 0; }
+ static int32_t load(const int16_t *p, uint32_t n) {
+ return n==3 && p[0]==-32768 && p[1]==123 && p[2]==32767 ? 4 : -1;
+ }
+ static int32_t play(int32_t cue) { active=1; return cue>=1 && cue<=4 ? 0 : -1; }
+ static int32_t stop(void) { active=0; return 0; }
+ static int32_t playing(void) { return active; }
+ static void close_audio(void) { active=0; }
+ static const struct simple_audio_api api={1,open_audio,load,play,stop,playing,close_audio};
+ const struct simple_audio_api *simple_miniaudio_api(void) { return &api; }
+ """);
+ TestC.exec(30,"gcc","-shared","-fPIC","-Isrc/main/c",source.toString(),"-o",library.toString());
+ assertEquals("",run(playback,library.toAbsolutePath().toString()));
+ }
+
+ @Test public void incompatibleBackend() throws Exception {
+ Path source=Path.of("build/objs/audio_bad.c");
+ Path library=Path.of("build/objs/audio_bad"+(TestC.OS.startsWith("Windows") ? ".dll" : ".so"));
+ Files.writeString(source,"""
+ #include "simple_audio.h"
+ static const struct simple_audio_api api={999};
+ const struct simple_audio_api *simple_miniaudio_api(void) { return &api; }
+ """);
+ TestC.exec(30,"gcc","-shared","-fPIC","-Isrc/main/c",source.toString(),"-o",library.toString());
+ assertTrue(run(missing,library.toAbsolutePath().toString()).contains("incompatible backend"));
+ }
+}
diff --git a/src/test/java/com/seaofnodes/simple/BrainFuckTest.java b/src/test/java/com/seaofnodes/simple/BrainFuckTest.java
index 63858df0..07e702c7 100644
--- a/src/test/java/com/seaofnodes/simple/BrainFuckTest.java
+++ b/src/test/java/com/seaofnodes/simple/BrainFuckTest.java
@@ -1,19 +1,21 @@
package com.seaofnodes.simple;
import com.seaofnodes.isa.eval.EvalRisc5;
-
import com.seaofnodes.isa.eval.EvalArm64;
-
import java.io.IOException;
+import org.junit.Test;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
import java.nio.file.Files;
import java.nio.file.Path;
-
import org.junit.Test;
import static org.junit.Assert.assertEquals;
public class BrainFuckTest {
@Test public void testBrainFuckAryu8() throws IOException {
+
+
String brain_fuck = "Hello World!\n";
String src =
"""
diff --git a/src/test/java/com/seaofnodes/simple/Chapter01Test.java b/src/test/java/com/seaofnodes/simple/Chapter01Test.java
index fa7fb68a..6db79c16 100644
--- a/src/test/java/com/seaofnodes/simple/Chapter01Test.java
+++ b/src/test/java/com/seaofnodes/simple/Chapter01Test.java
@@ -41,13 +41,8 @@ public class Chapter01Test {
}
@Test
- public void testBad2() {
- try {
- new CodeGen("return 0123;").parse();
- fail();
- } catch( RuntimeException e ) {
- assertEquals("Syntax error: integer values cannot start with '0'",e.getMessage());
- }
+ public void testOctal() {
+ assertEquals("return 83;",new CodeGen("return 0123;").parse().print());
}
@Test
diff --git a/src/test/java/com/seaofnodes/simple/Chapter21Test.java b/src/test/java/com/seaofnodes/simple/Chapter21Test.java
index c6d558eb..14a091ac 100644
--- a/src/test/java/com/seaofnodes/simple/Chapter21Test.java
+++ b/src/test/java/com/seaofnodes/simple/Chapter21Test.java
@@ -461,6 +461,7 @@ return i;
String src =
"""
val test_sqrt = { flt x ->
+ if( x==0.0 ) return 0.0; // Avoid 0/0: unordered comparisons must stay false.
flt epsilon = 1e-15;
flt guess = x;
while( 1 ) {
@@ -531,7 +532,8 @@ val sieve = { int N ->
String sprimes = sb.p("]").toString();
// Compile, link against native C; expect the above string of primes to be printed out by C
- TestC.runC(src, "sieve", sprimes,344);
+ // Load/Phi hoisting now requires the pointer before the loop entry.
+ TestC.runC(src, "sieve", sprimes,216);
// Evaluate on RISC5 emulator; expect return of an array of primes in
// the simulated heap.
diff --git a/src/test/java/com/seaofnodes/simple/Chapter25Test.java b/src/test/java/com/seaofnodes/simple/Chapter25Test.java
index 67e7f982..6b892c65 100644
--- a/src/test/java/com/seaofnodes/simple/Chapter25Test.java
+++ b/src/test/java/com/seaofnodes/simple/Chapter25Test.java
@@ -475,9 +475,9 @@ public class Chapter25Test {
// Elf files are sane
- // Sys depends on io, libc, text, char, collections, and array utilities.
+ // Software drawing and the optional native graphics interface.
assertArrayEquals(new String[]{"sys/adt", "sys/ary",
- "sys/aryi64", "sys/aryu8", "sys/char", "sys/io", "sys/libc", "sys/rt", "sys/scan", "sys/text"},sys_elf._deps);
+ "sys/aryi64", "sys/aryu8", "sys/audio", "sys/char", "sys/draw", "sys/gfx", "sys/io", "sys/libc", "sys/rt", "sys/scan", "sys/text", "sys/time"},sys_elf._deps);
assertArrayEquals(new String[]{"sys"},sys_elf._units);
assertSame("class:sys" ,sys_elf._clz._name);
}
diff --git a/src/test/java/com/seaofnodes/simple/DrawTest.java b/src/test/java/com/seaofnodes/simple/DrawTest.java
new file mode 100644
index 00000000..72e2cd9d
--- /dev/null
+++ b/src/test/java/com/seaofnodes/simple/DrawTest.java
@@ -0,0 +1,221 @@
+package com.seaofnodes.simple;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.Test;
+import com.seaofnodes.simple.codegen.CodeGen;
+import com.seaofnodes.simple.type.TypeInteger;
+import com.seaofnodes.simple.util.Ary;
+import static org.junit.Assert.*;
+
+// Native raster and game checks; no backend or display is needed.
+public class DrawTest {
+ @Test public void nativeClockCoordinates() throws Exception {
+ String clock=Files.readString(Path.of("docs/examples/Clock.smp"))
+ .replace("val main =","val _windowMain =");
+ StringBuilder source=new StringBuilder(clock+"""
+ val _check_noInline={ f64 turn, int expectedX, int expectedY ->
+ int x=360+_sin_noInline(turn)*128, y=232-_sin_noInline(turn+0.25)*128;
+ if( x<expectedX-1 || x>expectedX+1 || y<expectedY-1 || y>expectedY+1 ) {
+ sys.io.p("turn %f(turn): %(x),%(y) expected %(expectedX),%(expectedY)\\n");
+ return 1;
+ }
+ return 0;
+ };
+ val main={ ->
+ """);
+ for(int i=0;i<=60;i++) {
+ double turn=i/60.0;
+ int x=(int)(360+clockSine(turn)*128), y=(int)(232-clockSine(turn+0.25)*128);
+ source.append("if(_check_noInline(").append(turn).append(',').append(x).append(',').append(y)
+ .append(")!=0) return 1;");
+ }
+ source.append("return 0; };");
+ TestC.runSYS(source.toString(),"clockCoordinates","",-1);
+ }
+
+ private static double clockSine(double turn) {
+ turn-=(long)turn;
+ boolean negative=turn>0.5;
+ double x=(negative ? turn-0.5 : turn)*6.283185307179586;
+ double product=x*(3.141592653589793-x);
+ return (negative ? -16.0 : 16.0)*product/(49.34802200544679-4*product);
+ }
+
+ @Test public void nativeAnalogSweep() throws Exception {
+ String clock=Files.readString(Path.of("docs/examples/Clock.smp"))
+ .replace("val main =","val _windowMain =");
+ StringBuilder source=new StringBuilder(clock+"""
+ val _renderCheck_noInline={ int second, int millisecond, int expectedX, int expectedY ->
+ val c=new sys.draw.Canvas(720,540);
+ _analog_noInline(c,3,20,second,millisecond);
+ var found=false;
+ for( int y=expectedY-2; y<=expectedY+2; y++ )
+ for( int x=expectedX-2; x<=expectedX+2; x++ )
+ if( c.pixels[y*720+x]==0xc3ffcb ) found=true;
+ if( !found ) {
+ sys.io.p("missing second hand %(second).%(millisecond) expected %(expectedX),%(expectedY)\\n");
+ return 1;
+ }
+ // Every dial mark/hand must stay within the dial's bounds.
+ for( int y=0; y<540; y++ ) for( int x=0; x<720; x++ )
+ if( (x<204 || x>516 || y<76 || y>388) && c.pixels[y*720+x]!=0 ) {
+ sys.io.p("stray pixel %(x),%(y) color %x(c.pixels[y*720+x]) at %(second).%(millisecond)\\n");
+ return 2;
+ }
+ return 0;
+ };
+ val main={ ->
+ """);
+ for(int millis:new int[]{0,500,7500,15000,30000,45000,59500}) {
+ double turn=millis/60000.0;
+ int x=(int)(360+clockSine(turn)*128), y=(int)(232-clockSine(turn+0.25)*128);
+ source.append("if(_renderCheck_noInline(").append(millis/1000).append(',').append(millis%1000)
+ .append(',').append(x).append(',').append(y).append(")!=0) return 1;");
+ }
+ source.append("return 0; };");
+ TestC.runSYS(source.toString(),"clockAnalogSweep","",-1);
+ }
+
+ @Test public void buttonInteractionAndRendering() throws Exception {
+ TestC.runSYS("""
+ val main={ ->
+ val b=new sys.draw.Button(10,10,60,30,"GO");
+ if( !b.contains(10,10) || b.contains(70,20) || b.contains(20,40) ) return 1;
+ if( b.update(20,20,true) || !b.pressed || b.update(20,20,true) ) return 2;
+ if( !b.update(20,20,false) || b.pressed || b.update(20,20,false) ) return 3;
+ if( b.update(0,0,true) || b.update(20,20,true) || b.update(20,20,false) ) return 4;
+ b.update(20,20,true);
+ if( b.update(0,0,false) ) return 5;
+ b.update(20,20,true); b.enabled=false;
+ if( b.update(20,20,true) || b.pressed || b.update(20,20,false) ) return 6;
+ b.enabled=true;
+ val c=new sys.draw.Canvas(90,60);
+ b.update(20,20,false); b.draw(c,2,9,3,5);
+ if( c.pixels[10*90+10]!=9 || c.pixels[13*90+13]!=5 || c.pixels[0]!=0 ) return 7;
+ b.update(0,0,false); b.draw(c,2,9,3,5);
+ if( c.pixels[13*90+13]!=3 ) return 8;
+ return 0;
+ };
+ ""","drawButton","",-1);
+ }
+
+ @Test public void clockFacesWithoutWindow() throws Exception {
+ String clock=Files.readString(Path.of("docs/examples/Clock.smp"))
+ .replace("val main =","val _windowMain =");
+ CodeGen code=new CodeGen(null,null,new Ary<>(new String[]{TestC.RELEASE_SYS_DIR}),"ClockRaster",clock+"""
+ val main={ ->
+ val c=new sys.draw.Canvas(720,540);
+ _digit_noInline(c,10,10,8);
+ if( c.pixels[10*720+40]!=GREEN || c.pixels[82*720+40]!=GREEN || c.pixels[154*720+40]!=GREEN ) return 1;
+ _digit_noInline(c,10,10,1);
+ if( c.pixels[10*720+40]!=DIM || c.pixels[40*720+78]!=GREEN || c.pixels[40*720+14]!=DIM ) return 2;
+ c.clear(BACKGROUND); _digital_noInline(c,23,59,58);
+ if( c.pixels[214*720+254]!=GREEN || c.pixels[262*720+470]!=GREEN ) return 3;
+ c.clear(BACKGROUND); _analog_noInline(c,3,0,0,0);
+ if( c.pixels[232*720+430]!=GREEN || c.pixels[160*720+360]!=0xc3ffcb || c.pixels[232*720+360]!=GREEN ) return 4;
+ if( _sin_noInline(0.25)<0.999 || _sin_noInline(0.75)>-0.999 || _sin_noInline(1.0)!=0.0 ) return 5;
+ return 0;
+ };
+ return main();
+ """,126L,true,TypeInteger.BOT).driver(CodeGen.Phase.TypeCheck);
+ assertEquals("0",Eval2.eval(code,0,3000000));
+ }
+
+ @Test public void rectanglesAndColors() throws Exception {
+ TestC.runSYS("""
+ val main = { ->
+ val c = new sys.draw.Canvas(12,10);
+ u32 color = sys.draw.rgb(300,-1,128);
+ if( color!=16711808 ) return 1;
+ c.clear(7).fillRect(-2,-1,5,4,color);
+ for( int y=0; y<10; y++ )
+ for( int x=0; x<12; x++ )
+ if( c.pixels[y*12+x]!=(x<3 && y<3 ? color : 7) ) return 2;
+ c.clear(0).rect(2,2,7,6,1,9);
+ for( int y=0; y<10; y++ )
+ for( int x=0; x<12; x++ ) {
+ var inside = 2<=x<9 && 2<=y<8;
+ var border = inside && (x==2 || x==8 || y==2 || y==7);
+ if( c.pixels[y*12+x]!=(border ? 9 : 0) ) return 3;
+ }
+ c.clear(5).fillRect(20,0,8,8,1).rect(0,0,12,10,0,1);
+ c.fillRect(0,0,-1,5,1).pixel(-1,0,1).pixel(12,9,1);
+ for( int i=0; i<c.pixels#; i++ ) if( c.pixels[i]!=5 ) return 4;
+ return 0;
+ };
+ ""","drawRectangles","",-1);
+ }
+
+ @Test public void circlesLinesAndText() throws Exception {
+ TestC.runSYS("""
+ val main = { ->
+ val c = new sys.draw.Canvas(16,16);
+ c.fillCircle(8,8,3,1);
+ for( int y=0; y<16; y++ )
+ for( int x=0; x<16; x++ )
+ if( c.pixels[y*16+x]!=((x-8)*(x-8)+(y-8)*(y-8)<=9 ? 1 : 0) ) {
+ sys.io.p("circle mismatch %(x) %(y): %(c.pixels[y*16+x])\\n");
+ return 1;
+ }
+ c.clear(0).circle(8,8,3,1,2);
+ if( c.pixels[8*16+8]!=0 || c.pixels[8*16+11]!=2 || c.pixels[8*16+10]!=0 ) return 2;
+ c.clear(0).circle(8,8,3,3,2);
+ if( c.pixels[8*16+8]!=2 ) return 8;
+ c.clear(0).line(-5,8,20,8,3,3);
+ for( int y=0; y<16; y++ )
+ for( int x=0; x<16; x++ )
+ if( c.pixels[y*16+x]!=(7<=y<=9 ? 3 : 0) ) return 3;
+ c.clear(0).line(8,8,8,8,2,4);
+ if( c.pixels[8*16+8]!=4 || c.pixels[8*16+9]!=4 || c.pixels[9*16+9]!=0 ) return 4;
+ c.clear(0).line(2,2,13,13,1,5);
+ if( c.pixels[7*16+7]!=5 || c.pixels[7*16+8]!=0 ) return 5;
+ c.clear(0).text(0,0,"x",1,6).text(8,0,"X",1,6);
+ for( int y=0; y<5; y++ )
+ for( int x=0; x<3; x++ )
+ if( c.pixels[y*16+x]!=c.pixels[y*16+x+8] ) return 6;
+ c.clear(0).fillCircle(-1,-1,2,7);
+ if( c.pixels[0]!=7 || c.pixels[2]!=0 ) return 7;
+ return 0;
+ };
+ ""","drawCurves","",-1);
+ }
+
+ @Test public void graphicalGameWithoutWindow() throws Exception {
+ String game=Files.readString(Path.of("docs/examples/TicTacToeGraphical.smp"))
+ .replace("val main =","val _windowMain =");
+ TestC.runSYS(game+"""
+
+ val main = { ->
+ val board = new u8[9];
+ _reset(board);
+ if( _square(48,168)!=0 || _square(551,671)!=8 || _square(552,400)!=-1 || _square(100,167)!=-1 ) return 1;
+ if( !_move(board,4) || board[4]!='X' || board[0]!='O' ) return 2;
+ if( _sound(board)!=sys.audio.BOOP ) return 10;
+ if( _move(board,4) || _move(board,0) || _move(board,-1) || _move(board,9) ) return 3;
+ val moves = "1879";
+ _reset(board);
+ for( int i=0; i<moves#; i++ )
+ if( !_move(board,moves[i]-'1') ) return 4;
+ if( _winner(board)!='X' || _move(board,1) ) return 5;
+ if( _sound(board)!=sys.audio.WIN ) return 11;
+ val screen = new sys.draw.Canvas(600,780);
+ _draw(screen,board,-1);
+ if( screen.pixels[252*600+132]==screen.pixels[0] ) return 6;
+ _reset(board);
+ val draw = "12768";
+ for( int i=0; i<draw#; i++ ) _move(board,draw[i]-'1');
+ if( !_finished(board) || _winner(board)!=0 ) return 7;
+ _reset(board);
+ _move(board,0); _move(board,1); _move(board,3);
+ if( _winner(board)!='O' ) return 8;
+ if( _sound(board)!=sys.audio.LOSE ) return 12;
+ _draw(screen,board,-1);
+ _reset(board);
+ if( _finished(board) ) return 9;
+ _draw(screen,board,4);
+ return 0;
+ };
+ ""","drawGame","",-1);
+ }
+}
diff --git a/src/test/java/com/seaofnodes/simple/FloatComparisonNativeTest.java b/src/test/java/com/seaofnodes/simple/FloatComparisonNativeTest.java
new file mode 100644
index 00000000..c0d8be3a
--- /dev/null
+++ b/src/test/java/com/seaofnodes/simple/FloatComparisonNativeTest.java
@@ -0,0 +1,65 @@
+package com.seaofnodes.simple;
+
+import org.junit.Test;
+
+public class FloatComparisonNativeTest extends TestC {
+ @Test public void longFloatingBranches() throws Exception {
+ StringBuilder source=new StringBuilder("val far_noInline={f64 a,f64 b,i64[] out -> if(a<b){");
+ for(int i=0;i<32;i++) source.append("out[").append(i).append("]=").append(i+1).append(';');
+ source.append("""
+ return 1; } return 0; };
+ val main={ ->
+ val out=new i64[32];
+ if( far_noInline(1.0,2.0,out)!=1 || out[31]!=32 ) return 1;
+ out[31]=0;
+ if( far_noInline(2.0,1.0,out)!=0 || out[31]!=0 ) return 2;
+ f64 nan=1e300*1e300-1e300*1e300;
+ if( far_noInline(nan,1.0,out)!=0 || out[31]!=0 ) return 3;
+ if( far_noInline(1.0,nan,out)!=0 || out[31]!=0 ) return 4;
+ return 0;
+ };
+ """);
+ runSYS(source.toString(),"floatLongBranches","",-1);
+ }
+
+ @Test public void branchesAndValues() throws Exception {
+ String[] literals={"-1.0","0.0","2.0","1e300*1e300","-1e300*1e300",
+ "1e300*1e300-1e300*1e300"};
+ double[] values={-1,0,2,Double.POSITIVE_INFINITY,Double.NEGATIVE_INFINITY,Double.NaN};
+ StringBuilder source=new StringBuilder("""
+ val branch_noInline={ f64 a, f64 b ->
+ int bits=0;
+ if( a<b ) bits+=1;
+ if( a<=b ) bits+=2;
+ if( a==b ) bits+=4;
+ if( a!=b ) bits+=8;
+ if( a>=b ) bits+=16;
+ if( a>b ) bits+=32;
+ return bits;
+ };
+ val value_noInline={ f64 a, f64 b ->
+ val lt=a<b, le=a<=b, eq=a==b, ne=a!=b, ge=a>=b, gt=a>b;
+ return lt+le*2+eq*4+ne*8+ge*16+gt*32;
+ };
+ val check_noInline={ f64 a, f64 b, int expected, int tag ->
+ val branches=branch_noInline(a,b), values=value_noInline(a,b);
+ if( branches!=expected || values!=expected ) {
+ sys.io.p("case %(tag): branches %(branches), values %(values), expected %(expected)\\n");
+ return 1;
+ }
+ return 0;
+ };
+ val main={ ->
+ """);
+ for(int i=0;i<values.length;i++) for(int j=0;j<values.length;j++) {
+ double a=values[i],b=values[j];
+ int expected=(a<b ? 1 : 0)|(a<=b ? 2 : 0)|(a==b ? 4 : 0)|
+ (a!=b ? 8 : 0)|(a>=b ? 16 : 0)|(a>b ? 32 : 0);
+ source.append("if(check_noInline(").append(literals[i]).append(',').append(literals[j])
+ .append(',').append(expected).append(',').append(i*values.length+j)
+ .append(")!=0) return 1;");
+ }
+ source.append("return 0; };");
+ runSYS(source.toString(),"floatComparisons","",-1);
+ }
+}
diff --git a/src/test/java/com/seaofnodes/simple/GraphicsTest.java b/src/test/java/com/seaofnodes/simple/GraphicsTest.java
new file mode 100644
index 00000000..66ced278
--- /dev/null
+++ b/src/test/java/com/seaofnodes/simple/GraphicsTest.java
@@ -0,0 +1,127 @@
+package com.seaofnodes.simple;
+
+import com.seaofnodes.simple.util.Ary;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.TimeUnit;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+// These tests never open a window and do not need MiniFB installed.
+public class GraphicsTest {
+ private static String graphics, text, game, clock;
+
+ @BeforeClass public static void compilePrograms() throws Exception {
+ var paths = new Ary<>(new String[]{TestC.RELEASE_SYS_DIR});
+ graphics = TestC.compile(Files.readString(Path.of("docs/examples/Graphics.smp")),
+ "graphics_missing",paths,TestC.CALL_CONVENTION,null,null,-1);
+ text = TestC.compile("val main={ -> sys.io.p(\"text still works\\n\"); return 0; };",
+ "graphics_text",paths,TestC.CALL_CONVENTION,null,null,-1);
+ game = TestC.compile(Files.readString(Path.of("docs/examples/TicTacToeGraphical.smp")),
+ "graphics_game",paths,TestC.CALL_CONVENTION,null,null,-1);
+ clock = TestC.compile(Files.readString(Path.of("docs/examples/Clock.smp")),
+ "graphics_clock",paths,TestC.CALL_CONVENTION,null,null,-1);
+ }
+
+ private static String run(String exe, String backend, int exit) throws Exception {
+ return run(exe,backend,exit,null);
+ }
+
+ private static String run(String exe, String backend, int exit, Path cwd) throws Exception {
+ ProcessBuilder pb = new ProcessBuilder(Path.of(exe).toAbsolutePath().toString()).redirectErrorStream(true);
+ if (cwd!=null) pb.directory(cwd.toFile());
+ if( backend == null ) pb.environment().remove("SIMPLE_MINIFB");
+ else pb.environment().put("SIMPLE_MINIFB",backend);
+ Process child = pb.start();
+ try {
+ assertTrue("native program timed out",child.waitFor(10,TimeUnit.SECONDS));
+ String output = new String(child.getInputStream().readAllBytes(),java.nio.charset.StandardCharsets.UTF_8);
+ assertEquals(output,exit,child.exitValue());
+ return output;
+ } finally { if( child.isAlive() ) child.destroyForcibly(); }
+ }
+
+ @Test public void missingConfiguration() throws Exception {
+ Path dir=Files.createTempDirectory(Path.of("build/objs"),"graphics-missing-");
+ Path bin=Files.createDirectory(dir.resolve("bin"));
+ Path exe=Files.copy(Path.of(graphics),bin.resolve(Path.of(graphics).getFileName()));
+ String output = run(exe.toString(),null,1,dir);
+ assertTrue(output,output.contains("MiniFB backend not found"));
+ assertTrue(output,output.contains("make graphics"));
+ }
+
+ @Test public void discoverBackendWithoutConfiguration() throws Exception {
+ Path root=Files.createTempDirectory(Path.of("build/objs"),"graphics-discovery-");
+ String abi=TestC.OS.startsWith("Windows") ? "x86_64_v2_win64" : "x86_64_v2_SystemV";
+ String name=TestC.OS.startsWith("Windows") ? "simple_minifb.dll" : "libsimple_minifb.so";
+ Path source=root.resolve("backend.c");
+ Files.writeString(source,"""
+ #include "simple_graphics.h"
+ #include <stdio.h>
+ static int32_t open_window(const char *s,uint32_t w,uint32_t h) {
+ puts("discovered mock MiniFB"); return -1;
+ }
+ static int32_t present(const uint32_t *p,uint32_t n) { return 0; }
+ static int32_t poll_events(int32_t n) { return 0; }
+ static int32_t position(void) { return 0; }
+ static int32_t wait_events(int64_t n) { return 0; }
+ static void close_window(void) {}
+ static const struct simple_graphics_api api={SIMPLE_GRAPHICS_VERSION,
+ open_window,present,poll_events,position,position,poll_events,poll_events,close_window,wait_events};
+ const struct simple_graphics_api *simple_minifb_api(void) { return &api; }
+ """);
+ Path mock=root.resolve(name);
+ TestC.exec(30,"gcc","-shared","-fPIC","-Isrc/main/c",source.toString(),"-o",mock.toString());
+ Path cwd=Files.createDirectory(root.resolve("empty"));
+ Path bin=Files.createDirectory(root.resolve("bin"));
+ Path exe=Files.copy(Path.of(graphics),bin.resolve(Path.of(graphics).getFileName()));
+ Path beside=Files.copy(mock,bin.resolve(name));
+ assertTrue(run(exe.toString(),null,1,cwd).contains("discovered mock MiniFB"));
+ // Explicit bad overrides must not silently fall back to the mock.
+ assertTrue(run(exe.toString(),"no-such-backend",1,cwd).contains("cannot load MiniFB"));
+ Files.delete(beside);
+ Path release=Files.createDirectories(root.resolve("build/release/"+abi));
+ Files.copy(mock,release.resolve(name));
+ Path examples=Files.createDirectories(root.resolve("build/examples/"+abi));
+ Path example=Files.copy(exe,examples.resolve(exe.getFileName()));
+ assertTrue(run(example.toString(),null,1,cwd).contains("discovered mock MiniFB"));
+ assertTrue(run(exe.toString(),null,1,root).contains("discovered mock MiniFB"));
+ }
+
+ @Test public void disabledGraphics() throws Exception {
+ assertTrue(run(graphics,"off",1).contains("disabled"));
+ }
+
+ @Test public void gameWithoutBackend() throws Exception {
+ assertTrue(run(game,"off",1).contains("disabled"));
+ }
+
+ @Test public void clockWithoutBackend() throws Exception {
+ assertTrue(run(clock,"off",1).contains("disabled"));
+ }
+
+ @Test public void missingLibrary() throws Exception {
+ String missing = Path.of("build","no-such-minifb-backend").toAbsolutePath().toString();
+ String output = run(graphics,missing,1);
+ assertTrue(output,output.contains("cannot load MiniFB backend"));
+ }
+
+ @Test public void textIgnoresGraphicsConfiguration() throws Exception {
+ assertEquals("text still works\n",run(text,"off",0));
+ assertEquals("text still works\n",run(text,"no-such-backend",0));
+ }
+
+ @Test public void incompatibleLibrary() throws Exception {
+ Path source = Path.of("build/objs/graphics_incompatible.c");
+ Path library = Path.of("build/objs/graphics_incompatible" +
+ (TestC.OS.startsWith("Windows") ? ".dll" : ".so"));
+ Files.writeString(source,"""
+ #include "simple_graphics.h"
+ static const struct simple_graphics_api api = {999};
+ const struct simple_graphics_api *simple_minifb_api(void) { return &api; }
+ """);
+ TestC.exec(30,"gcc","-shared","-fPIC","-Isrc/main/c",source.toString(),"-o",library.toString());
+ assertTrue(run(graphics,library.toAbsolutePath().toString(),1).contains("incompatible MiniFB backend"));
+ }
+}
diff --git a/src/test/java/com/seaofnodes/simple/IntegerLiteralTest.java b/src/test/java/com/seaofnodes/simple/IntegerLiteralTest.java
new file mode 100644
index 00000000..975042c2
--- /dev/null
+++ b/src/test/java/com/seaofnodes/simple/IntegerLiteralTest.java
@@ -0,0 +1,46 @@
+package com.seaofnodes.simple;
+
+import com.seaofnodes.simple.codegen.CodeGen;
+import com.seaofnodes.simple.type.TypeInteger;
+import com.seaofnodes.simple.type.TypeFloat;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+public class IntegerLiteralTest {
+ private static void value(String expression, long expected) {
+ var code = new CodeGen("return "+expression+";").parse();
+ assertEquals(expression,TypeInteger.constant(expected),code.expr()._type);
+ }
+
+ @Test public void valuesAndBitPatterns() {
+ value("0x00e47b57",14973783);
+ value("0XfF",255);
+ value("025755",11245); // A: rows 010, 101, 111, 101, 101.
+ value("000000",0);
+ value("0x7fffffffffffffff",Long.MAX_VALUE);
+ value("0x8000000000000000",Long.MIN_VALUE);
+ value("0xffffffffffffffff",-1);
+ value("0777777777777777777777",Long.MAX_VALUE);
+ value("01000000000000000000000",Long.MIN_VALUE);
+ value("01777777777777777777777",-1);
+ value("0x00000000000000000001",1);
+ value("-0x2a + 052",0);
+ value("0x1e+2",32); // e and f are hex digits, not float markers.
+ value("0xff & 017",15);
+ value("017<<0x4",240);
+ value("0x0f",15);
+ value("0123",83);
+ value("000000000000000000000000000000000001",1);
+ assertEquals(TypeFloat.constant(12.5),new CodeGen("return 012.5;").parse().expr()._type);
+ assertEquals(TypeFloat.constant(12.0),new CodeGen("return 012f;").parse().expr()._type);
+ }
+
+ @Test public void nativeLiterals() throws Exception {
+ TestC.runSF("""
+ val check_noInline = { i64 mask, u32 color, int glyph ->
+ return mask==-1 && color==14973783 && glyph==11245 ? 0 : 1;
+ };
+ val main = { -> return check_noInline(0xffffffffffffffff,0x00e47b57,025755); };
+ ""","basedLiterals","",-1);
+ }
+}
diff --git a/src/test/java/com/seaofnodes/simple/ParserDiagnosticTest.java b/src/test/java/com/seaofnodes/simple/ParserDiagnosticTest.java
index 2b7eea78..ef26a943 100644
--- a/src/test/java/com/seaofnodes/simple/ParserDiagnosticTest.java
+++ b/src/test/java/com/seaofnodes/simple/ParserDiagnosticTest.java
@@ -30,7 +30,18 @@ public class ParserDiagnosticTest {
diagnostic("return 9223372036854775808;",1,8,"Integer literal is outside the signed 64-bit range");
diagnostic("return 1e;",1,8,"Invalid floating-point literal '1e'");
diagnostic("return 1.2.3f;",1,8,"Invalid floating-point literal '1.2.3f'");
- diagnostic("return 012;",1,8,"Syntax error: integer values cannot start with '0'");
+ diagnostic("return 018;",1,8,"Invalid octal integer literal '018'");
+ }
+
+ @Test public void testBasedIntegerLiteralErrors() {
+ diagnostic("return 0x;",1,8,"Hexadecimal integer literal requires at least one digit");
+ diagnostic("return 0x12g;",1,8,"Invalid hexadecimal integer literal '0x12g'");
+ diagnostic("return 0128;",1,8,"Invalid octal integer literal '0128'");
+ diagnostic("return 0x_ff;",1,8,"Invalid hexadecimal integer literal '0x_ff'");
+ diagnostic("return 09;",1,8,"Invalid octal integer literal '09'");
+ diagnostic("return 0x10000000000000000;",1,8,"Hexadecimal integer literal exceeds 64 bits");
+ diagnostic("return 02000000000000000000000;",1,8,"Octal integer literal exceeds 64 bits");
+ diagnostic("int 0xabc;",1,5,"Expected an identifier, found '0xabc'");
}
@Test public void testUnclosedCommentsAndStrings() {
diff --git a/src/test/java/com/seaofnodes/simple/SeparateCompilationTest.java b/src/test/java/com/seaofnodes/simple/SeparateCompilationTest.java
index 1853aa48..1c278791 100644
--- a/src/test/java/com/seaofnodes/simple/SeparateCompilationTest.java
+++ b/src/test/java/com/seaofnodes/simple/SeparateCompilationTest.java
@@ -10,6 +10,22 @@ import org.junit.Test;
import static org.junit.Assert.*;
public class SeparateCompilationTest {
+ @Test public void testImportedReadOnlyPrimitiveArray() throws Exception {
+ Path build=Path.of("build/objs/separate");
+ Files.createDirectories(build);
+ Path lib=Files.createTempDirectory(build,"pixels-");
+ new CodeGen(null,lib.toString(),null,"Pixels",
+ "val first={ u32[~] pixels -> pixels[0]; };",123,true,TypeInteger.BOT)
+ .driver(TestC.CPU_PORT,TestC.CALL_CONVENTION,false,false);
+ var paths=new Ary<>(new String[]{lib.toString()});
+ CodeGen code=new CodeGen(null,"build/objs",paths,"PixelClient","""
+ val pixels=new u32[2];
+ pixels[0]=4294967295;
+ return Pixels.first(pixels);
+ """,123,true,TypeInteger.BOT).driver(CodeGen.Phase.TypeCheck);
+ assertEquals("4294967295",Eval2.eval(code,0));
+ }
+
@Test public void testImportedNativeExposure() throws Exception {
Path build=Path.of("build/objs/separate");
Files.createDirectories(build);
diff --git a/src/test/java/com/seaofnodes/simple/StreamIOTest.java b/src/test/java/com/seaofnodes/simple/StreamIOTest.java
index f3491314..7b459e75 100644
--- a/src/test/java/com/seaofnodes/simple/StreamIOTest.java
+++ b/src/test/java/com/seaofnodes/simple/StreamIOTest.java
@@ -131,6 +131,10 @@ public class StreamIOTest extends TestC {
gcc.addAll(linkObjs(code));
gcc.add(runtimeObject("simple_crt"));
gcc.add(runtimeObject("simple_args"));
+ gcc.add(runtimeObject("simple_graphics"));
+ gcc.add(runtimeObject("simple_audio"));
+ gcc.add(runtimeObject("simple_time"));
+ if( !OS.startsWith("Windows") ) gcc.add("-ldl");
gcc.addAll(new String[]{"-lm","-o",exe});
exec(TEST_TIMEOUT_SECONDS,gcc.asAry());
assertEquals("ok\n",exec(TEST_TIMEOUT_SECONDS,exe));
diff --git a/src/test/java/com/seaofnodes/simple/StringFormatTest.java b/src/test/java/com/seaofnodes/simple/StringFormatTest.java
index 53a10092..05e801dc 100644
--- a/src/test/java/com/seaofnodes/simple/StringFormatTest.java
+++ b/src/test/java/com/seaofnodes/simple/StringFormatTest.java
@@ -128,6 +128,10 @@ public class StringFormatTest extends TestC {
@Test public void testWrongTypesAndReservedNames() {
rejects("return \"%d(1.5)\";", "must be a");
+ rejects("return \"%t(1.5)\";", "must be a");
+ rejects("return \"%tQ(0)\";", "Expected a time format");
+ rejects("return \"%.2t(0)\";", "Precision is not supported");
+ rejects("return \"%+t(0)\";", "Invalid flags");
rejects("return \"%s(17)\";", "must be a");
rejects("return \"%(null)\";", "Cannot format");
rejects("val f={int int -> int;}; return 0;", "Expected an identifier");
diff --git a/src/test/java/com/seaofnodes/simple/TestC.java b/src/test/java/com/seaofnodes/simple/TestC.java
index dd7999c9..63bb98da 100644
--- a/src/test/java/com/seaofnodes/simple/TestC.java
+++ b/src/test/java/com/seaofnodes/simple/TestC.java
@@ -190,6 +190,11 @@ public abstract class TestC {
params.add(OS.startsWith("Windows") ? "-Wl,--image-base,0x400000" : "-no-pie");
if( cfile!=null ) params.add(cfile); // Associated C driver, usually has a `main`
params.addAll(objects);
+ // The loader has no MiniFB dependency and opens nothing until gfx.open.
+ params.add(runtimeObject("simple_graphics"));
+ params.add(runtimeObject("simple_audio"));
+ params.add(runtimeObject("simple_time"));
+ if( !OS.startsWith("Windows") ) params.add("-ldl");
// Standalone Simple programs export `simple_main`; the small C
// runtime owns native main and calls the Simple argument converter
// before making the ABI-qualified entry call.
diff --git a/src/test/java/com/seaofnodes/simple/TimeTest.java b/src/test/java/com/seaofnodes/simple/TimeTest.java
new file mode 100644
index 00000000..c3cdc6cf
--- /dev/null
+++ b/src/test/java/com/seaofnodes/simple/TimeTest.java
@@ -0,0 +1,176 @@
+package com.seaofnodes.simple;
+
+import com.seaofnodes.simple.codegen.CodeGen;
+import com.seaofnodes.simple.type.TypeInteger;
+import com.seaofnodes.simple.util.Ary;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.concurrent.TimeUnit;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+public class TimeTest extends TestC {
+ private static void checks(String body, String name) throws Exception {
+ runSYS("val main={ -> "+body+" return 0; };",name,"",-1);
+ }
+
+ @Test public void builtInFormats() throws Exception {
+ runSYS("""
+ val main={ ->
+ i64 t=sys.time.parse("2026-11-12T00:34:56.789Z");
+ sys.io.p("%t(t)|%tF(t)|%tr(t)|%tR(t)|%tM(83456)|%tD(t)|%tT(t)\\n");
+ sys.io.p("[%30t(0)][%-10tr(sys.time.hours(12))]\\n");
+ sys.io.p("%tr(0)|%tr(sys.time.hours(13))|%tM(-1)|%tM(-9223372036854775807-1)");
+ return 0;
+ };
+ ""","timeFormats",
+ "2026-11-12T00:34:56.789Z|2026-11-12T00:34:56.789Z|12:34am|00:34|01:23.456|12/11/26|00:34:56\n"+
+ "[ 1970-01-01T00:00:00.000Z][12:00pm ]\n"+
+ "12:00am|1:00pm|-00:00.001|-153722867280912:55.808",-1);
+ }
+
+ @Test public void calendarAgainstJava() throws Exception {
+ long[] values={-62135596800000L,-2203977600000L,-2203891200000L,-86400001,-86400000,-1,0,1,
+ 951782400000L,1709164800123L,1794443696789L,253402300799999L};
+ DateTimeFormatter iso=DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC);
+ StringBuilder src=new StringBuilder("val show_noInline={i64 t -> sys.io.p(\"%t(t)|\"); val d=sys.time.utc(t); sys.io.p(\"%(d.weekday)\\n\"); }; val main={ -> ");
+ StringBuilder expected=new StringBuilder();
+ for (long value:values) {
+ src.append("show_noInline(").append(value).append(");");
+ var dt=Instant.ofEpochMilli(value).atOffset(ZoneOffset.UTC);
+ expected.append(iso.format(dt)).append('|').append(dt.getDayOfWeek().getValue()%7).append('\n');
+ }
+ src.append("return 0; };");
+ runSYS(src.toString(),"timeCalendar",expected.toString(),-1);
+ }
+
+ @Test public void parseAndConstruction() throws Exception {
+ checks("""
+ if( sys.time.parse("1970-01-01T00:00:00Z")!=0 ) return 1;
+ if( sys.time.parse("1970-01-01T01:00:00.123+01:00")!=123 ) return 2;
+ if( sys.time.parse("1969-12-31T16:00:00-08:00")!=0 ) return 3;
+ if( sys.time.parse("1969-12-31T23:59:59.999Z")!=-1 ) return 4;
+ if( sys.time.parse("1969-12-31T23:59:59.999+01:00")!=-3600001 ) return 5;
+ if( sys.time.parse("1970-01-01T00:00:30+00:00:30")!=0 ) return 6;
+ if( sys.time.parse("2000-02-29T00:00:00Z")!=951782400000 ) return 7;
+ if( sys.time.makeUTC(2024,2,29,0,0,0,123)!=1709164800123 ) return 8;
+ if( sys.time.makeUTC(1900,2,29,0,0,0,0)!=-1 || sys.time.makeUTC(2024,2,29,24,0,0,0)!=-1 ) return 9;
+ if( sys.time.utc(sys.time.MIN-1).year!=0 || sys.time.utc(sys.time.MAX+1).year!=0 ) return 10;
+ ""","timeParse");
+ String[] bad={"","now","2026-11-12","2026-11-12T00:00:00","0000-01-01T00:00:00Z",
+ "1900-02-29T00:00:00Z","2026-02-29T00:00:00Z","2026-13-01T00:00:00Z",
+ "2026-11-00T00:00:00Z","2026-11-12T24:00:00Z","2026-11-12T00:60:00Z",
+ "2026-11-12T00:00:60Z","2026-11-12T00:00:00.1Z","2026-11-12T00:00:00.1234Z",
+ "2026-11-12T00:00:00+24:00","2026-11-12T00:00:00+01:60","2026-11-12T00:00:00Zx",
+ "2026-11-12T00:00:00.00xZ","0001-01-01T00:00:00+01:00"};
+ StringBuilder src=new StringBuilder("val main={ -> ");
+ for (int i=0;i<bad.length;++i)
+ src.append("if(sys.time.parse(\"").append(bad[i]).append("\")!=-1) return ").append(i+1).append(';');
+ src.append("return 0; };");
+ runSYS(src.toString(),"timeBadParse","",-1);
+ }
+
+ @Test public void durationArithmetic() throws Exception {
+ checks("""
+ if( sys.time.seconds(2)!=2000 || sys.time.minutes(2)!=120000 || sys.time.hours(2)!=7200000 || sys.time.days(2)!=172800000 ) return 1;
+ if( sys.time.toSeconds(-1999)!=-1 || sys.time.toMinutes(119999)!=1 || sys.time.toHours(7200000)!=2 || sys.time.toDays(172800000)!=2 ) return 2;
+ if( sys.time.seconds(9223372036854775)!=9223372036854775000 || sys.time.seconds(-9223372036854775)!=-9223372036854775000 ) return 3;
+ if( sys.time.seconds(9223372036854776)!=-1 || sys.time.seconds(-9223372036854776)!=-1 ) return 4;
+ if( sys.time.remaining(5,10)!=0 || sys.time.remaining(10,5)!=5 || sys.time.remaining(9223372036854775807,-1)!=9223372036854775807 ) return 5;
+ if( sys.time.fromNanos(-1999999)!=-1 || sys.time.toNanos(1234)!=1234000000 || sys.time.toNanos(9223372036855)!=-1 ) return 6;
+ ""","timeArithmetic");
+ }
+
+ @Test public void timerOrderingAndMissedTicks() throws Exception {
+ checks("""
+ val q=new sys.time.Timers();
+ if( q.poll(0)!=-1 || q.nextDeadline()!=-1 ) return 1;
+ i64 a=q.afterAt(100,20), b=q.afterAt(100,10), c=q.afterAt(100,10);
+ if( q.nextDeadline()!=110 || q.poll(109)!=-1 || q.poll(110)!=b || q.poll(110)!=c || q.poll(110)!=-1 ) return 2;
+ if( !q.cancel(a) || q.cancel(a) || q.cancel(-1) || q.nextDeadline()!=-1 ) return 3;
+ i64 repeat=q.everyAt(100,10);
+ if( q.poll(135)!=repeat || q.nextDeadline()!=140 || q.poll(135)!=-1 ) return 4;
+ if( q.poll(140)!=repeat || q.nextDeadline()!=150 || !q.cancel(repeat) ) return 5;
+ for( int i=0; i<20; i++ ) if( q.afterAt(200,i)<0 ) return 11;
+ i64 prev=-1;
+ for( int i=0; i<20; i++ ) { i64 id=q.poll(300); if( id<=prev ) return 6; prev=id; }
+ if( q.nextDeadline()!=-1 || q.poll(1000)!=-1 ) return 7;
+ if( q.afterAt(-1,1)!=-1 || q.afterAt(0,-1)!=-1 || q.everyAt(0,0)!=-1 || q.afterAt(9223372036854775807,1)!=-1 ) return 8;
+ i64 last=q.everyAt(9223372036854775806,1);
+ if( last<0 || q.poll(9223372036854775807)!=last || q.nextDeadline()!=-1 ) return 9;
+ ""","timeTimers");
+ }
+
+ private static String run(String source, String name, String zone) throws Exception {
+ String exe=compile(source,name,new Ary<>(new String[]{RELEASE_SYS_DIR}),CALL_CONVENTION,null,null,-1);
+ ProcessBuilder pb=new ProcessBuilder(exe).redirectErrorStream(true);
+ if (zone!=null) pb.environment().put("TZ",zone);
+ Process child=pb.start();
+ try {
+ assertTrue("time program timed out",child.waitFor(10,TimeUnit.SECONDS));
+ String out=new String(child.getInputStream().readAllBytes(),StandardCharsets.UTF_8);
+ assertEquals(out,0,child.exitValue());
+ return out;
+ } finally { if (child.isAlive()) child.destroyForcibly(); }
+ }
+
+ @Test public void localZoneAndDaylightSaving() throws Exception {
+ String out=run("""
+ val show_noInline={u8[~] s ->
+ i64 t=sys.time.parse(s); val d=sys.time.local(t);
+ sys.io.p("%(sys.time.formatLocal(t,sys.time.ISO))|%(d.offsetSeconds)|%b(d.daylight)|%(sys.time.zoneName(t))\\n");
+ };
+ val main={ -> show_noInline("2026-01-01T00:00:00Z"); show_noInline("2026-07-01T00:00:00Z"); return 0; };
+ ""","timeZone","EST5EDT,M3.2.0/2,M11.1.0/2");
+ assertEquals("2025-12-31T19:00:00.000-05:00|-18000|false|EST\n2026-06-30T20:00:00.000-04:00|-14400|true|EDT\n",out);
+ }
+
+ @Test public void liveClocksAndWaits() throws Exception {
+ String out=run("""
+ val main={ ->
+ i64 start=sys.time.monotonic();
+ i64 ns=sys.time.nanoTime();
+ if( start<0 || ns/1000000<start || ns/1000000>sys.time.monotonic() ) return 1;
+ if( sys.time.sleep(10)!=0 || sys.time.monotonic()-start<10 ) return 2;
+ i64 deadline=sys.time.monotonic()+10;
+ if( sys.time.sleepUntil(deadline)!=0 || sys.time.monotonic()<deadline ) return 3;
+ if( sys.time.sleepUntil(0)!=0 || sys.time.sleep(-1)!=-1 || sys.time.sleepUntil(-1)!=-1 ) return 4;
+ i64 wall=sys.time.now();
+ if( wall<1700000000000 || wall>4102444800000 ) return 5;
+ return 0;
+ };
+ ""","timeClocks",null);
+ assertEquals("",out);
+ }
+
+ @Test public void pureCalendarInEvaluator() {
+ CodeGen code=new CodeGen("src/main/smp",null,null,"TimeEval","""
+ val s="%t(-1)|%tM(1234)";
+ val expected="1969-12-31T23:59:59.999Z|00:01.234";
+ if( s#!=expected# ) return 1;
+ for( int i=0; i<s#; i++ ) if( s[i]!=expected[i] ) return 2;
+ return 0;
+ """,123,true,TypeInteger.BOT).driver(CodeGen.Phase.TypeCheck);
+ assertEquals("0",Eval2.eval(code,0,100000));
+ }
+
+ @Test public void wideLibrarySerialization() throws Exception {
+ // A growing stdlib crossed the historical byte-sized alias/count limit.
+ // Exercise a separately compiled library whose live memory spans it.
+ StringBuilder source=new StringBuilder("struct Wide { ");
+ for (int i=0;i<300;++i) source.append("int !f").append(i).append("=0;");
+ source.append("}; val make_noInline={int x -> val v=new Wide();");
+ for (int i : new int[]{0,255,299}) source.append("v.f").append(i).append("=x+").append(i).append(';');
+ source.append("return v; };");
+ Path library=Files.createTempDirectory(Path.of("build/objs"),"wide-memory-");
+ new CodeGen(null,library.toString(),null,"WideMemory",source.toString(),123,true,TypeInteger.BOT)
+ .driver(CPU_PORT,CALL_CONVENTION,false,false);
+ run("val main={ -> val v=WideMemory.make_noInline(7); return v.f0!=7 || v.f255!=262 || v.f299!=306; };",
+ "timeWideMemory",new Ary<>(new String[]{library.toString(),RELEASE_SYS_DIR}),
+ CALL_CONVENTION,null,null,"",-1);
+ }
+}
diff --git a/src/test/java/com/seaofnodes/simple/codegen/PrintRegTestSupport.java b/src/test/java/com/seaofnodes/simple/codegen/PrintRegTestSupport.java
index b255bcf7..b0f0f225 100644
--- a/src/test/java/com/seaofnodes/simple/codegen/PrintRegTestSupport.java
+++ b/src/test/java/com/seaofnodes/simple/codegen/PrintRegTestSupport.java
@@ -61,7 +61,7 @@ public class PrintRegTestSupport {
visit.put(marker,t);
var method = com.seaofnodes.simple.print.ASMPrinter.class.getDeclaredMethod(
"printConstantPool",int.class,com.seaofnodes.simple.util.SB.class,
- com.seaofnodes.simple.util.BAOS.class,java.util.HashMap.class,boolean.class,String.class);
+ com.seaofnodes.simple.util.BAOS.class,java.util.Map.class,boolean.class,String.class);
method.setAccessible(true);
try {
var sb = new com.seaofnodes.simple.util.SB();