diff --git a/README.md b/README.md
index e747010..ab0fe8e 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@
|---|---|
| `aspirin.sax` / `app-ORION-aspirin.zip` | Playable game: auto-shuffle at start, tap a tile next to the gap to slide it, SHUFFLE button, move counter, SOLVED! |
| `aspirin_solver.sax` / `app-ORION-aspirin_solver.zip` | Demo: shuffles 30 moves, then an auto solver plays one move per second until SOLVED! (it records the shuffle and replays the inverse moves) |
+| `aspirinV2.sax` / `app-ORION-aspirinV2.zip` | Same game with fixed cells: nothing moves, each cell shows the number it holds on white, the empty cell takes the board colour |
## Run in the simulator
@@ -17,13 +18,14 @@ The simulator is headless; to watch the screen run `viewer\run_viewer.cmd`
## Build
```bash
-python build.py # gen .sax -> sedonac .sab -> launcher backup zips (both apps)
+python build.py # gen .sax -> sedonac .sab -> launcher backup zips (all apps)
python verify_logic.py # offline scan-engine model test of the game
python verify_logic.py --solver
+python verify_logic.py --v2
```
`gen_aspirin.py` holds layout, colours, shuffle length (`SHUFFLE_MOVES`) and solver delay
-(`SOLVE_DELAY`). Paths to the Sedona install are at the top of `build.py`.
+(`SOLVE_DELAY`); `gen_aspirinV2.py` generates V2. Paths to the Sedona install are at the top of `build.py`.
## How it works
@@ -33,3 +35,11 @@ python verify_logic.py --solver
by the shuffle); it latches the blank's cell and the blank follows automatically.
- Shuffle uses a logistic-map RNG made of `Reset`/`Mul2` blocks; only legal moves, so always solvable.
- Solved = blank home and the weighted sum of tile offsets is 0 (rearrangement inequality).
+
+### V2 (fixed cells)
+
+- 16 `NumericSelectLabel` cells; `in` = the number held (0 = empty), text list ` ;1;2;...;15;`.
+- `backgroundColor` is linked from logic: white for a number, board colour for the empty cell.
+- State = 16 cell values in `ADemux2` latches (boot loads the solved board). Blank position is
+ derived from the values; a move latches 0 into the touched cell and its value into the blank.
+- Solved = sum of i * value_i over cells 1..15 is 1240 (its unique maximum).
diff --git a/app-ORION-aspirinV2.zip b/app-ORION-aspirinV2.zip
new file mode 100644
index 0000000..5d4f94e
Binary files /dev/null and b/app-ORION-aspirinV2.zip differ
diff --git a/aspirinV2.sab b/aspirinV2.sab
new file mode 100644
index 0000000..8f22121
Binary files /dev/null and b/aspirinV2.sab differ
diff --git a/aspirinV2.sax b/aspirinV2.sax
new file mode 100644
index 0000000..d336b75
--- /dev/null
+++ b/aspirinV2.sax
@@ -0,0 +1,2719 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build.py b/build.py
index 5bc6194..ce4eee0 100644
--- a/build.py
+++ b/build.py
@@ -1,4 +1,4 @@
-"""Builds aspirin(.sax/_solver.sax) -> .sab and packages app-ORION-.zip for simulatorLauncher.exe.
+"""Builds aspirin(.sax/_solver.sax/V2.sax) -> .sab and packages app-ORION-.zip for simulatorLauncher.exe.
Zip layout matches the launcher's ExampleApp backups:
app.sax, device/app.sab, device/kits.scode, device/fonts/*.oxfl, fontBankXL/*.oxfl
@@ -22,9 +22,24 @@ env = dict(os.environ, sedona_home=SEDONA)
with zipfile.ZipFile(EXAMPLE) as src:
scode = src.read("device/kits.scode")
-for name, args in (("aspirin", []), ("aspirin_solver", ["--solver"])):
- subprocess.check_call([sys.executable, "gen_aspirin.py"] + args)
- subprocess.check_call([os.path.join(SEDONA, "bin", "sedonac.exe"), f"{name}.sax"], env=env)
+JAVA = r"C:\Niagara\Niagara-4.15.1.16\jre\bin\java.exe" # fallback when sedonac.exe finds no JVM
+
+
+def sedonac(sax):
+ if subprocess.call([os.path.join(SEDONA, "bin", "sedonac.exe"), sax], env=env) == 0:
+ return
+ lib = os.path.join(SEDONA, "lib")
+ subprocess.check_call([JAVA, f"-Dsedona.home={SEDONA}", "-cp",
+ os.pathsep.join([os.path.join(lib, "sedonac.jar"),
+ os.path.join(lib, "sedona.jar")]),
+ "sedonac.Main", sax])
+
+
+for name, gen in (("aspirin", ["gen_aspirin.py"]),
+ ("aspirin_solver", ["gen_aspirin.py", "--solver"]),
+ ("aspirinV2", ["gen_aspirinV2.py"])):
+ subprocess.check_call([sys.executable] + gen)
+ sedonac(f"{name}.sax")
out = f"app-ORION-{name}.zip"
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
z.write(f"{name}.sax", "app.sax")
diff --git a/gen_aspirinV2.py b/gen_aspirinV2.py
new file mode 100644
index 0000000..1c40914
--- /dev/null
+++ b/gen_aspirinV2.py
@@ -0,0 +1,345 @@
+"""Generates aspirinV2.sax - the 4x4 sliding (15) puzzle with fixed cells.
+
+Same game as aspirin.sax, different screen model: nothing moves on screen.
+- 16 fixed cells, each a NumericSelectLabel. Its `in` is the number the cell holds
+ (0 = empty) and its text list " ;1;2;...;15;" turns that into the label.
+- backgroundColor is linked from logic: white for a number, board colour for the empty cell.
+- Board state = cell values v[1..16] held in ADemux2.out2 (boot loads 1..15, 0 = solved).
+- Blank cell: cb = sum_j col_j*(v_j==0), rb likewise (Reset maps v 0..1 -> col..0).
+- Cell i is movable when (col-cb)^2 + (row-rb)^2 == 1. A rising edge of (movable AND
+ (touched OR shuffle-pick)) fires a one-scan OneShot; in that scan cell i latches 0 and the
+ blank cell latches the moved value MV = sum_i (OS_i ? v_i : 0).
+- Shuffle: same logistic-map RNG and direction codes 2*dc+dr as aspirin.sax.
+- Solved: sum_i i*v_i (cells 1..15) == 1240, the unique maximum (rearrangement inequality).
+"""
+import base64
+
+OUT = "aspirinV2.sax"
+N = 4
+PITCH = 105 # cell pitch in px
+CELL = 100 # cell size in px
+X0, Y0 = 40, 40 # top-left cell
+SHUFFLE_SECS = 10.0
+SOLVED_SUM = sum(i * i for i in range(1, 16)) # 1240
+
+# RGB565 colours
+C_PAGE = 0x10A2
+C_BOARD = 0x31A6
+C_TILE = 0xFFFF
+C_TILE_TXT = 0xC000 # dark red
+C_WHITE = 0xFFFF
+C_RED = 0xD800
+C_GREEN = 0x07E0
+C_GREY = 0xAD55
+
+comps = []
+links = []
+_id = [0]
+
+
+def nid():
+ _id[0] += 1
+ return _id[0]
+
+
+def buf(s):
+ return base64.b64encode(s.encode("ascii") + b"\0").decode()
+
+
+class Node:
+ def __init__(self, name, typ, props=None, path=None):
+ assert len(name) <= 7, name
+ self.name, self.typ, self.props = name, typ, props or {}
+ self.kids = []
+ self.path = path
+ self.id = None
+
+ def add(self, name, typ, **props):
+ n = Node(name, typ, props, f"{self.path}/{name}")
+ self.kids.append(n)
+ return n
+
+ def emit(self, ind):
+ self.id = nid()
+ pad = " " * ind
+ out = [f'{pad}']
+ for k, v in self.props.items():
+ if isinstance(v, bool):
+ v = "true" if v else "false"
+ elif isinstance(v, float):
+ v = repr(round(v, 6))
+ out.append(f'{pad} ')
+ for kid in self.kids:
+ out += kid.emit(ind + 1)
+ out.append(f"{pad}")
+ return out
+
+
+def link(a, aslot, b, bslot):
+ links.append(f' ')
+
+
+def root(name, typ, **props):
+ return Node(name, typ, props, f"/{name}")
+
+
+def reset(parent, name, in_min, in_max, out_min, out_max):
+ """Affine map (clamped) using control::Reset - lets constants live in config props."""
+ return parent.add(name, "control::Reset", inMin=float(in_min), inMax=float(in_max),
+ outMin=float(out_min), outMax=float(out_max))
+
+
+def tree(parent, prefix, typ, sources, slot):
+ """Add4/Or4 tree over up to 16 sources; returns the final block."""
+ groups = [sources[i:i + 4] for i in range(0, len(sources), 4)]
+ partial = []
+ for gi, grp in enumerate(groups):
+ a = parent.add(f"{prefix}{gi}", typ)
+ for k, src in enumerate(grp):
+ link(src, slot, a, f"in{k + 1}")
+ partial.append(a)
+ tot = parent.add(f"{prefix}T", typ)
+ for k, p in enumerate(partial):
+ link(p, "out", tot, f"in{k + 1}")
+ return tot
+
+
+CELLS = range(1, 17)
+
+
+def colrow(i):
+ return (i - 1) % N, (i - 1) // N
+
+
+# ---------------------------------------------------------------- service
+service = root("service", "sys::Folder")
+plat = service.add("OrionPl", "ontrolOrion::OrionPlatformService")
+oxbase = plat.add("OxBase", "ontrolOx::OxBase", userTimeout=255, lcdSleepTimeout=60,
+ lcdSleepBrightness=30)
+users = service.add("users", "sys::UserService")
+users.add("admin", "sys::User", cred="wHy/iCHUdXWkccFgala3nl9uamg=", perm=2147483647, prov=255)
+users.add("guest", "sys::User", cred="jVShQjDWIkrr+o3lYLcAPPvNVio=", perm=63)
+service.add("sox", "sox::SoxService")
+driver = root("driver", "sys::Folder")
+
+# ---------------------------------------------------------------- ox (screen)
+ox = root("ox", "sys::Folder")
+page = ox.add("Game", "ontrolOx::OxPage", backgroundColor=C_PAGE, touchTimeout=0)
+page.add("Board", "ontrolOx::Geometry", x=X0 - 5, y=Y0 - 5,
+ width=N * PITCH + 5, height=N * PITCH + 5, backgroundColor=C_BOARD)
+NUMBERS = " ;" + "".join(f"{n};" for n in range(1, 16)) # index 0 = empty
+assert len(NUMBERS) <= 64
+cells = {}
+for i in CELLS:
+ c, r = colrow(i)
+ cells[i] = page.add(f"Cell{i:02d}", "ontrolOx::NumericSelectLabel",
+ x=X0 + c * PITCH, y=Y0 + r * PITCH, width=CELL, height=CELL,
+ fontSize=48, fontAttributes=1, attributes=24, text=NUMBERS,
+ textColor=C_TILE_TXT,
+ backgroundColor=C_BOARD if i == 16 else C_TILE)
+RX, RW = 490, 290
+page.add("Title", "ontrolOx::PushButtonLabel", x=RX, y=40, width=RW, height=70,
+ fontSize=52, fontAttributes=1, attributes=24, text=buf("ASPIRIN"),
+ textColor=C_RED, backgroundColor=C_PAGE)
+page.add("Sub", "ontrolOx::PushButtonLabel", x=RX, y=110, width=RW, height=34,
+ fontSize=18, attributes=24, text=buf("15 PUZZLE V2"),
+ textColor=C_GREY, backgroundColor=C_PAGE)
+moves_lbl = page.add("Moves", "ontrolOx::NumericLabel", x=RX, y=160, width=RW, height=50,
+ fontSize=32, attributes=24, text=buf("Moves: %.0f"),
+ textColor=C_WHITE, backgroundColor=C_PAGE)
+shuf_btn = page.add("Shuffle", "ontrolOx::PushButtonLabel", x=RX + 30, y=225, width=RW - 60,
+ height=70, fontSize=32, fontAttributes=1, attributes=24,
+ text=buf("SHUFFLE"), textColor=C_WHITE, backgroundColor=C_RED)
+busy_lbl = page.add("Busy", "ontrolOx::BooleanLabel", x=RX, y=310, width=RW, height=36,
+ fontSize=24, attributes=24,
+ trueText=buf("Shuffling..."), falseText=buf(" "),
+ trueTextColor=C_GREY, falseTextColor=C_PAGE,
+ trueBackgroundColor=C_PAGE, falseBackgroundColor=C_PAGE)
+solved_lbl = page.add("Solved", "ontrolOx::BooleanLabel", x=RX, y=395, width=RW, height=60,
+ fontSize=36, fontAttributes=1, attributes=24,
+ trueText=buf("SOLVED!"), falseText=buf(" "),
+ trueTextColor=C_GREEN, falseTextColor=C_PAGE,
+ trueBackgroundColor=C_PAGE, falseBackgroundColor=C_PAGE)
+link(page, "pageId", oxbase, "homePageId")
+
+# ---------------------------------------------------------------- logic
+# folder order = scan order: glob (blank, RNG) -> cells (move detect) -> mid (moved value)
+# -> latch (state) -> post (display, win, counter). glob/cells read the previous scan's state.
+logic = root("logic", "sys::Folder")
+g = logic.add("glob", "sys::Folder")
+cf = {i: logic.add(f"c{i:02d}", "sys::Folder") for i in CELLS}
+mid = logic.add("mid", "sys::Folder")
+lat = logic.add("latch", "sys::Folder")
+post = logic.add("post", "sys::Folder")
+
+true_c = g.add("TRUE", "control::ConstBool", out=True)
+boot = g.add("Boot", "control::OneShot", pulseWidth=0.5) # loads the solved board
+link(true_c, "out", boot, "in")
+shreq = g.add("ShReq", "control::Or2")
+link(shuf_btn, "touch", shreq, "in1")
+link(boot, "out", shreq, "in2")
+shuf = g.add("Shuf", "control::OneShot", pulseWidth=SHUFFLE_SECS)
+link(shreq, "out", shuf, "in")
+link(shuf, "out", busy_lbl, "in")
+not_boot = g.add("NotBoot", "control::Not")
+link(boot, "out", not_boot, "in")
+enable = g.add("En", "control::And2") # shuffle moves allowed
+link(shuf, "out", enable, "in1")
+link(not_boot, "out", enable, "in2")
+
+# RNG: x' = 0.003 + 0.9945*4*x*(1-x) (logistic map, never hits 0)
+inv = reset(g, "RInv", 0, 1, 1, 0)
+mul = g.add("RMul", "control::Mul2")
+rx = reset(g, "RX", 0, 0.25, 0.003, 0.9975)
+link(rx, "out", inv, "in")
+link(rx, "out", mul, "in1")
+link(inv, "out", mul, "in2")
+link(mul, "out", rx, "in")
+# x -> direction code {-2,-1,1,2}; breakpoints at arcsine quartiles for ~uniform picks
+code = g.add("Code", "control::Linearize",
+ x0=0.0, y0=-2.0, x1=0.146, y1=-2.0, x2=0.147, y2=-1.0, x3=0.5, y3=-1.0,
+ x4=0.501, y4=1.0, x5=0.853, y5=1.0, x6=0.854, y6=2.0, x7=1.0, y7=2.0)
+link(rx, "out", code, "in")
+
+# state latches (created now so glob can link to them; they execute in the latch folder)
+L = {i: {} for i in CELLS}
+for i in CELLS:
+ lf = lat.add(f"L{i:02d}", "sys::Folder")
+ L[i]["init"] = reset(lf, "Init", 0, 1, i % 16, i % 16) # constant: solved value
+ L[i]["mv"] = lf.add("Mv", "control::ASW") # OS ? 0 : MV
+ L[i]["in"] = lf.add("In", "control::ASW") # boot ? init : move value
+ L[i]["zm"] = lf.add("ZM", "control::And2") # blank AND any move
+ L[i]["s"] = lf.add("S", "control::Or4")
+ L[i]["v"] = lf.add("V", "control::ADemux2") # out2 = cell value
+V = {i: L[i]["v"] for i in CELLS}
+
+# blank position from the cell values
+bc = {i: reset(g, f"BC{i:02d}", 0, 1, colrow(i)[0], 0) for i in CELLS}
+br = {i: reset(g, f"BR{i:02d}", 0, 1, colrow(i)[1], 0) for i in CELLS}
+for i in CELLS:
+ link(V[i], "out2", bc[i], "in")
+ link(V[i], "out2", br[i], "in")
+cb = tree(g, "CB", "control::Add4", [bc[i] for i in CELLS], "out")
+rb = tree(g, "RB", "control::Add4", [br[i] for i in CELLS], "out")
+
+# ---------------------------------------------------------------- per cell: move detect
+P = {}
+for i in CELLS:
+ f = cf[i]
+ c, r = colrow(i)
+ p = {}
+ p["dc"] = reset(f, "Dc", 0, 3, c, c - 3) # col - cb
+ p["dr"] = reset(f, "Dr", 0, 3, r, r - 3) # row - rb
+ p["sc"] = f.add("Sc", "control::Mul2")
+ p["sr"] = f.add("Sr", "control::Mul2")
+ p["d2"] = f.add("D2", "control::Add2")
+ p["val"] = f.add("Val", "ontrolControl::InRange", lowLimit=0.5, highLimit=1.5)
+ p["k"] = f.add("K", "control::Add4") # 2*dc + dr
+ p["m"] = f.add("M", "control::Cmpr")
+ p["h"] = f.add("H", "control::And2")
+ p["p"] = f.add("P", "control::Or2")
+ p["g"] = f.add("G", "control::And2")
+ p["os"] = f.add("OS", "control::OneShot", pulseWidth=0.03)
+ p["z"] = f.add("Z", "ontrolControl::InRange", lowLimit=-0.5, highLimit=0.5) # empty
+ p["out"] = f.add("Out", "control::ASW") # OS ? v : 0
+ P[i] = p
+ link(cb, "out", p["dc"], "in")
+ link(rb, "out", p["dr"], "in")
+ link(p["dc"], "out", p["sc"], "in1")
+ link(p["dc"], "out", p["sc"], "in2")
+ link(p["dr"], "out", p["sr"], "in1")
+ link(p["dr"], "out", p["sr"], "in2")
+ link(p["sc"], "out", p["d2"], "in1")
+ link(p["sr"], "out", p["d2"], "in2")
+ link(p["d2"], "out", p["val"], "in")
+ link(p["dc"], "out", p["k"], "in1")
+ link(p["dc"], "out", p["k"], "in2")
+ link(p["dr"], "out", p["k"], "in3")
+ link(p["k"], "out", p["m"], "x")
+ link(code, "out", p["m"], "y")
+ link(p["m"], "xey", p["h"], "in1")
+ link(enable, "out", p["h"], "in2")
+ link(cells[i], "touch", p["p"], "in1")
+ link(p["h"], "out", p["p"], "in2")
+ link(p["val"], "out", p["g"], "in1")
+ link(p["p"], "out", p["g"], "in2")
+ link(p["g"], "out", p["os"], "in")
+ link(V[i], "out2", p["z"], "in")
+ link(V[i], "out2", p["out"], "in2")
+ link(p["os"], "out", p["out"], "s1")
+
+# ---------------------------------------------------------------- mid: moved value
+mv = tree(mid, "MV", "control::Add4", [P[i]["out"] for i in CELLS], "out")
+anym = tree(mid, "AM", "control::Or4", [P[i]["os"] for i in CELLS], "out")
+
+# ---------------------------------------------------------------- latch wiring
+for i in CELLS:
+ l, p = L[i], P[i]
+ link(mv, "out", l["mv"], "in1") # in2 unlinked = 0
+ link(p["os"], "out", l["mv"], "s1")
+ link(l["mv"], "out", l["in"], "in1")
+ link(l["init"], "out", l["in"], "in2")
+ link(boot, "out", l["in"], "s1")
+ link(p["z"], "out", l["zm"], "in1")
+ link(anym, "out", l["zm"], "in2")
+ link(boot, "out", l["s"], "in1")
+ link(p["os"], "out", l["s"], "in2")
+ link(l["zm"], "out", l["s"], "in3")
+ link(l["in"], "out", l["v"], "in")
+ link(l["s"], "out", l["v"], "s1")
+
+# ---------------------------------------------------------------- post: display, win, counter
+for i in CELLS:
+ df = post.add(f"D{i:02d}", "sys::Folder")
+ num = df.add("Num", "control::F2I")
+ link(V[i], "out2", num, "in")
+ link(num, "out", cells[i], "in")
+ bgf = reset(df, "BgF", 0, 1, C_BOARD, C_TILE) # 0 -> board, 1..15 -> white
+ link(V[i], "out2", bgf, "in")
+ bg = df.add("Bg", "control::F2I")
+ link(bgf, "out", bg, "in")
+ link(bg, "out", cells[i], "backgroundColor")
+
+wt = {i: reset(post, f"W{i:02d}", 0, 15, 0, 15 * i) for i in range(1, 16)} # i * v_i
+for i in wt:
+ link(V[i], "out2", wt[i], "in")
+w = tree(post, "WS", "control::Add4", list(wt.values()), "out")
+w_ok = post.add("WOk", "ontrolControl::InRange",
+ lowLimit=SOLVED_SUM - 0.5, highLimit=SOLVED_SUM + 0.5)
+link(w, "out", w_ok, "in")
+not_sh = post.add("NotSh", "control::Not")
+link(shuf, "out", not_sh, "in")
+solved = post.add("Solved", "control::And2")
+link(w_ok, "out", solved, "in1")
+link(not_sh, "out", solved, "in2")
+link(solved, "out", solved_lbl, "in")
+
+cnt = post.add("Cnt", "control::Count")
+link(anym, "out", cnt, "in")
+link(true_c, "out", cnt, "enable")
+link(shuf, "out", cnt, "r")
+cntf = post.add("CntF", "control::I2F")
+link(cnt, "out", cntf, "in")
+link(cntf, "out", moves_lbl, "in")
+
+# ---------------------------------------------------------------- write
+body = []
+for rt in (service, driver, logic, ox):
+ body += rt.emit(1)
+
+# checksums pinned to the kits inside the Orion simulator's kits.scode
+kits = {"sys": "d3984c51", "control": "808b7db3", "inet": "25648ba7",
+ "ontrolControl": "254170a5", "ontrolOrion": "ebaa6c82", "ontrolOx": "641b8633",
+ "ontrolPlatform": "5c12ff9d", "sox": "397a84dd"}
+xml = ["", "", ""]
+xml += [f' ' for k, c in kits.items()]
+xml += ["", "",
+ ' ',
+ f' ',
+ ' ']
+xml += body
+xml += ["", ""] + links + ["", "", ""]
+
+with open(OUT, "w", newline="\n") as fh:
+ fh.write("\n".join(xml))
+print(f"{OUT}: {_id[0]} components, {len(links)} links")
diff --git a/verify_logic.py b/verify_logic.py
index 3221525..6028472 100644
--- a/verify_logic.py
+++ b/verify_logic.py
@@ -345,6 +345,128 @@ def main():
print("re-shuffle OK")
+def astar(s0):
+ """Weighted A* on a 16-tuple (0 = blank); returns the list of cell indices to press."""
+ import heapq
+ goal = tuple(list(range(1, 16)) + [0])
+
+ def h(s):
+ return sum(abs(i // 4 - (v - 1) // 4) + abs(i % 4 - (v - 1) % 4)
+ for i, v in enumerate(s) if v)
+
+ pq = [(h(s0), 0, s0, [])]
+ seen = {s0: 0}
+ while pq:
+ _, gc, s, p = heapq.heappop(pq)
+ if s == goal:
+ return p
+ z = s.index(0)
+ for dz in (-1, 1, -4, 4):
+ n = z + dz
+ if n < 0 or n > 15 or (dz in (-1, 1) and n // 4 != z // 4):
+ continue
+ l = list(s)
+ l[z], l[n] = l[n], l[z]
+ ns = tuple(l)
+ if seen.get(ns, 1e9) > gc + 1:
+ seen[ns] = gc + 1
+ heapq.heappush(pq, (gc + 1 + 1.5 * h(ns), gc + 1, ns, p + [n]))
+ if len(seen) > 2_000_000:
+ return None
+
+
+def main_v2():
+ """aspirinV2.sax: fixed cells, numbers shown via each cell's `in`, empty cell recoloured."""
+ tops, paths = load("aspirinV2.sax")
+ for c in paths.values():
+ c.start()
+ now = 0
+ goal = tuple(list(range(1, 16)) + [0])
+ cell = [paths[f"/ox/Game/Cell{i:02d}"] for i in range(1, 17)]
+
+ def step(n=1):
+ nonlocal now
+ for _ in range(n):
+ now += SCAN_MS + random.randint(-3, 3)
+ run_scan(tops, now)
+
+ def state():
+ return tuple(int(c.g("in")) for c in cell)
+
+ def check():
+ s = state()
+ assert sorted(s) == list(range(16)), s
+ for v, c in zip(s, cell): # empty cell = board colour, numbers on white
+ assert int(c.g("backgroundColor")) == (0x31A6 if v == 0 else 0xFFFF), (s, v)
+
+ def show():
+ s = state()
+ return "\n".join(" ".join(f"{v:3d}" if v else " ." for v in s[r * 4:r * 4 + 4])
+ for r in range(4))
+
+ def solved():
+ return paths["/logic/post/Solved"].g("out", False)
+
+ def moves():
+ return paths["/logic/post/CntF"].g("out")
+
+ def press(k):
+ cell[k].v["touch"] = True
+ step(2)
+ cell[k].v["touch"] = False
+ step(2)
+
+ step(3)
+ assert state() == goal, "boot did not load the solved board: %s" % (state(),)
+ step(120)
+ check()
+ print("after shuffle:\n" + show())
+ assert state() != goal and not solved() and moves() == 0
+
+ z = state().index(0)
+ far = next(k for k in range(16) if abs(k // 4 - z // 4) + abs(k % 4 - z % 4) >= 2)
+ before = state()
+ press(far)
+ assert state() == before, "non-adjacent cell moved"
+ press(z)
+ assert state() == before, "pressing the empty cell changed the board"
+ n = z - 1 if z % 4 else z + 1
+ v = before[n]
+ press(n)
+ s = state()
+ assert s[n] == 0 and s[z] == v and moves() == 1, "valid move failed"
+ cell[z].v["touch"] = True # hold: exactly one move back
+ step(10)
+ cell[z].v["touch"] = False
+ step(2)
+ assert state() == before and moves() == 2, "hold did not move exactly once"
+ check()
+
+ path = astar(state())
+ assert path is not None, "solver gave up"
+ print("solving in", len(path), "presses")
+ for k in path:
+ press(k)
+ step(5)
+ print(show())
+ check()
+ assert state() == goal and solved(), "solved flag not set"
+ print("SOLVED flag OK, moves:", moves())
+
+ paths["/ox/Game/Shuffle"].v["touch"] = True
+ step(2)
+ paths["/ox/Game/Shuffle"].v["touch"] = False
+ step(120)
+ check()
+ assert state() != goal and not solved() and moves() == 0
+ print("re-shuffle OK")
+
+
if __name__ == "__main__":
import sys
- main_solver() if "--solver" in sys.argv else main()
+ if "--solver" in sys.argv:
+ main_solver()
+ elif "--v2" in sys.argv:
+ main_v2()
+ else:
+ main()
diff --git a/viewer/OxViewer.java b/viewer/OxViewer.java
index 0a05f38..660223f 100644
--- a/viewer/OxViewer.java
+++ b/viewer/OxViewer.java
@@ -15,6 +15,8 @@ public class OxViewer extends JPanel {
SoxClient client;
SoxComponent page;
SoxComponent[] tiles = new SoxComponent[16];
+ SoxComponent[] cells = new SoxComponent[17]; // aspirinV2: fixed cells Cell01..Cell16
+ boolean v2;
SoxComponent[] labels = new SoxComponent[LABELS.length];
String lastBoard = "";
@@ -22,7 +24,9 @@ public class OxViewer extends JPanel {
this.client = client;
SoxComponent app = client.loadApp();
page = load(app, "ox", "Game");
- for (int t = 1; t <= 15; t++) tiles[t] = load(page, String.format("Tile%02d", t));
+ v2 = load(page, "Cell01") != null;
+ if (v2) for (int k = 1; k <= 16; k++) cells[k] = load(page, String.format("Cell%02d", k));
+ else for (int t = 1; t <= 15; t++) tiles[t] = load(page, String.format("Tile%02d", t));
for (int i = 0; i < LABELS.length; i++) labels[i] = load(page, LABELS[i]);
setPreferredSize(new Dimension(800, 480));
}
@@ -40,6 +44,7 @@ public class OxViewer extends JPanel {
void poll() throws Exception {
for (SoxComponent c : tiles) if (c != null) client.update(c, 0x06);
+ for (SoxComponent c : cells) if (c != null) client.update(c, 0x06);
for (SoxComponent c : labels) if (c != null) client.update(c, 0x06);
String b = boardText();
if (!b.equals(lastBoard)) {
@@ -77,12 +82,19 @@ public class OxViewer extends JPanel {
String boardText() {
String[] cells = new String[16];
java.util.Arrays.fill(cells, " .");
- for (int t = 1; t <= 15; t++) {
+ int whites = 0;
+ if (v2) for (int k = 1; k <= 16; k++) {
+ int n = i(this.cells[k], "in");
+ if (n != 0) cells[k - 1] = String.format("%2d", n);
+ if ((i(this.cells[k], "backgroundColor") & 0xFFFF) == 0xFFFF) whites++;
+ }
+ else for (int t = 1; t <= 15; t++) {
int col = (i(tiles[t], "x") - 40) / 105, row = (i(tiles[t], "y") - 40) / 105;
if (col >= 0 && col < 4 && row >= 0 && row < 4) cells[row * 4 + col] = String.format("%2d", t);
}
StringBuilder sb = new StringBuilder();
for (int r = 0; r < 4; r++) sb.append(r == 0 ? "" : " | ").append(String.join(" ", java.util.Arrays.copyOfRange(cells, r * 4, r * 4 + 4)));
+ if (v2) sb.append(" white=").append(whites);
return sb.toString();
}
@@ -102,7 +114,13 @@ public class OxViewer extends JPanel {
g.fillRect(0, 0, 800, 480);
g.setColor(c565(0x31A6));
g.fillRect(35, 35, 425, 425);
- for (int t = 1; t <= 15; t++) {
+ if (v2) for (int k = 1; k <= 16; k++) {
+ SoxComponent w = cells[k];
+ String[] opts = str(w, "text").split(";");
+ int n = i(w, "in");
+ box(g, w, n < opts.length ? opts[n].trim() : "", i(w, "textColor"), i(w, "backgroundColor"), true, true);
+ }
+ else for (int t = 1; t <= 15; t++) {
SoxComponent w = tiles[t];
box(g, w, str(w, "text"), i(w, "textColor"), i(w, "backgroundColor"), true, true);
}
@@ -140,6 +158,9 @@ public class OxViewer extends JPanel {
Thread.sleep(500);
}
}
+ if (GraphicsEnvironment.isHeadless()) { // -Djava.awt.headless=true: console log only
+ while (true) { v.poll(); Thread.sleep(200); }
+ }
JFrame f = new JFrame("Orion Screen - aspirin (live via SOX " + host + ":" + port + ")");
f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
f.add(v);