commit 0728acd99a697851e0ce3a2eaaba6d2c92f7116e Author: Arda Aydin Date: Thu Oct 15 08:18:58 2026 +0300 Add aspirin 15-puzzle and auto-solver apps for Ontrol Orion - gen_aspirin.py generates aspirin.sax (playable) and aspirin_solver.sax (30-move shuffle, then one inverse move per second) from stock kits - build.py compiles with sedonac and packages simulator backup zips - verify_logic.py models the Sedona scan engine to test both apps offline - viewer/OxViewer.java shows the headless simulator's screen live over SOX Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fee52ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +run/ +viewer/*.class +viewer/*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..e747010 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# aspirin + +15-puzzle (sliding 1-15) for the Ontrol **Orion** (800x480), built only from stock Sedona kits +(`control`, `ontrolControl`, `ontrolOx`, `ontrolOrion`), so it runs on the stock simulator. + +| App | What it does | +|---|---| +| `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) | + +## Run in the simulator + +`simulatorLauncher.exe` → folder button → pick a `app-ORION-*.zip` → Start Simulator. +The simulator is headless; to watch the screen run `viewer\run_viewer.cmd` +(live view over SOX as `guest`, display only). + +## Build + +```bash +python build.py # gen .sax -> sedonac .sab -> launcher backup zips (both apps) +python verify_logic.py # offline scan-engine model test of the game +python verify_logic.py --solver +``` + +`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`. + +## How it works + +- Each tile is a `PushButtonLabel` whose `x`/`y` are linked from logic, so tiles move. +- Tile state = offset from home cell held in `ADemux2` latches; blank cell = 3 - sum(offsets). +- A tile moves on a one-scan `OneShot` when it is next to the blank and touched (or picked + 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). diff --git a/app-ORION-aspirin.zip b/app-ORION-aspirin.zip new file mode 100644 index 0000000..36bb756 Binary files /dev/null and b/app-ORION-aspirin.zip differ diff --git a/app-ORION-aspirin_solver.zip b/app-ORION-aspirin_solver.zip new file mode 100644 index 0000000..fd4ea48 Binary files /dev/null and b/app-ORION-aspirin_solver.zip differ diff --git a/aspirin.sab b/aspirin.sab new file mode 100644 index 0000000..ae1eb37 Binary files /dev/null and b/aspirin.sab differ diff --git a/aspirin.sax b/aspirin.sax new file mode 100644 index 0000000..96ce409 --- /dev/null +++ b/aspirin.sax @@ -0,0 +1,2154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/aspirin_solver.sab b/aspirin_solver.sab new file mode 100644 index 0000000..45e11d0 Binary files /dev/null and b/aspirin_solver.sab differ diff --git a/aspirin_solver.sax b/aspirin_solver.sax new file mode 100644 index 0000000..1f9e209 --- /dev/null +++ b/aspirin_solver.sax @@ -0,0 +1,2483 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build.py b/build.py new file mode 100644 index 0000000..5bc6194 --- /dev/null +++ b/build.py @@ -0,0 +1,37 @@ +"""Builds aspirin(.sax/_solver.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 +""" +import os +import subprocess +import sys +import zipfile + +SEDONA = r"C:\Users\aa\OneDrive - Ontrol\Documents\_is\NiagaraAXSedona\sedona" +SIM = os.path.join(SEDONA, "ontrol", "ontrolSimulator") +EXAMPLE = os.path.join(SIM, "ExampleApp", "app-ORION-VM_miniini.zip") # source of Orion kits.scode +FONTS = os.path.join(SEDONA, "ontrol", "fontBankXL") +USED_FONTS = ["18pt Tahoma.oxfl", "24pt Tahoma.oxfl", "32pt Tahoma.oxfl", + "bold 32pt Tahoma.oxfl", "bold 36pt Tahoma.oxfl", "bold 48pt Tahoma.oxfl", + "bold 52pt Tahoma.oxfl"] + +here = os.path.dirname(os.path.abspath(__file__)) +os.chdir(here) +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) + out = f"app-ORION-{name}.zip" + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: + z.write(f"{name}.sax", "app.sax") + z.write(f"{name}.sab", "device/app.sab") + z.writestr("device/kits.scode", scode) + for f in USED_FONTS: + p = os.path.join(FONTS, f) + z.write(p, f"device/fonts/{f}") + z.write(p, f"fontBankXL/{f}") + print("wrote", out) diff --git a/gen_aspirin.py b/gen_aspirin.py new file mode 100644 index 0000000..22329ab --- /dev/null +++ b/gen_aspirin.py @@ -0,0 +1,442 @@ +"""Generates aspirin.sax - a 4x4 sliding (15) puzzle for the Ontrol Orion (800x480). + +Built only from stock kits (control, ontrolControl, ontrolOx, ontrolOrion), so it runs +on the stock simulator scode. + +How it works +- Each tile t is a PushButtonLabel whose x/y are linked from logic, so tiles move on screen. +- Tile state = offset from its home cell (du, dv), held in ADemux2.out2 (starts at 0 = solved). +- Blank cell: cb = 3 - sum(du), rb = 3 - sum(dv) (cells form a permutation). +- Tile 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 that latches the blank's cell into + the tile; the blank then becomes the tile's old cell automatically. +- Shuffle: a logistic-map RNG (free running every scan) picks a direction code + 2*dc+dr in {-2,-1,1,2}; the tile whose code matches moves. Runs for a few seconds, + at startup and whenever SHUFFLE is pressed. Only legal moves => always solvable. +- Solved check: blank home AND sum_t t*(linear offset_t) == 0 (rearrangement inequality). +""" +import base64 +import sys + +# --solver: demo variant (aspirin_solver.sax). Shuffle makes exactly SHUFFLE_MOVES moves +# (no immediate back-steps) and pushes each move's code on a stack; after a pause the +# auto solver pops the stack and plays the inverse move every SOLVE_PERIOD seconds. +# No stock function block can search for a solution, so the solver undoes the shuffle. +SOLVER = "--solver" in sys.argv +OUT = "aspirin_solver.sax" if SOLVER else "aspirin.sax" +SHUFFLE_MOVES = 30 +STACK = 32 +SOLVE_DELAY = 2.0 + +N = 4 +PITCH = 105 # tile pitch in px +TILE = 100 # tile size in px +X0, Y0 = 40, 40 # top-left tile +SHUFFLE_SECS = 10.0 + +# 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 = [] # xml lines +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 sum_tree(parent, prefix, sources, slot): + """Sum of up to 16 float sources with Add4 blocks; returns the final Add4.""" + groups = [sources[i:i + 4] for i in range(0, len(sources), 4)] + partial = [] + for gi, g in enumerate(groups): + a = parent.add(f"{prefix}{gi}", "control::Add4") + for k, src in enumerate(g): + link(src, slot, a, f"in{k + 1}") + partial.append(a) + tot = parent.add(f"{prefix}T", "control::Add4") + for k, p in enumerate(partial): + link(p, "out", tot, f"in{k + 1}") + return tot + + +def or_tree(parent, prefix, sources, slot): + groups = [sources[i:i + 4] for i in range(0, len(sources), 4)] + partial = [] + for gi, g in enumerate(groups): + o = parent.add(f"{prefix}{gi}", "control::Or4") + for k, src in enumerate(g): + link(src, slot, o, f"in{k + 1}") + partial.append(o) + tot = parent.add(f"{prefix}T", "control::Or4") + for k, p in enumerate(partial): + link(p, "out", tot, f"in{k + 1}") + return tot + + +# ---------------------------------------------------------------- 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) +tiles = {} +for t in range(1, 16): + hc, hr = (t - 1) % N, (t - 1) // N + tiles[t] = page.add(f"Tile{t:02d}", "ontrolOx::PushButtonLabel", + x=X0 + hc * PITCH, y=Y0 + hr * PITCH, width=TILE, height=TILE, + fontSize=48, fontAttributes=1, attributes=24, + text=buf(str(t)), textColor=C_TILE_TXT, backgroundColor=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("AUTO SOLVER" if SOLVER else "15 PUZZLE"), + 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) +if SOLVER: + solving_lbl = page.add("Solving", "ontrolOx::BooleanLabel", x=RX, y=350, width=RW, + height=36, fontSize=24, attributes=24, + trueText=buf("Auto-solving..."), falseText=buf(" "), + trueTextColor=C_WHITE, 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 +logic = root("logic", "sys::Folder") +g = logic.add("glob", "sys::Folder") # executes before tile folders + +true_c = g.add("TRUE", "control::ConstBool", out=True) + +# shuffle control: startup pulse OR button -> OneShot(SHUFFLE_SECS) +boot = g.add("Boot", "control::OneShot", pulseWidth=0.5) +link(true_c, "out", boot, "in") +shreq = g.add("ShReq", "control::Or2") +link(boot, "out", shreq, "in2") +if SOLVER: + # SHUFFLE only accepted when the stack is empty (puzzle solved / idle) + btn_ok = g.add("BtnOk", "control::And2") + link(shuf_btn, "touch", btn_ok, "in1") + link(btn_ok, "out", shreq, "in1") + shreq_p = g.add("ShReqP", "control::OneShot", pulseWidth=0.05) # one-scan request + link(shreq, "out", shreq_p, "in") + shuf = g.add("Shuf", "control::SRLatch") # set by request, reset after SHUFFLE_MOVES + link(shreq_p, "out", shuf, "s") +else: + link(shuf_btn, "touch", shreq, "in1") + shuf = g.add("Shuf", "control::OneShot", pulseWidth=SHUFFLE_SECS) + link(shreq, "out", shuf, "in") +link(shuf, "out", busy_lbl, "in") + +# RNG: x' = 0.003 + 0.9945*4*x*(1-x) (logistic map, never hits 0) +inv = reset(g, "RInv", 0, 1, 1, 0) # 1 - x +mul = g.add("RMul", "control::Mul2") # x(1-x) +rx = reset(g, "RX", 0, 0.25, 0.003, 0.9975) # next x +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") + +if SOLVER: + # sel = code the tiles compare against: random code while shuffling, else inverse of stack top + neg_top = reset(g, "NegTop", -2, 2, 2, -2) # -top (linked from stack below) + bt = g.add("BT", "control::Cmpr") # random pick would undo last move + link(code, "out", bt, "x") + link(neg_top, "out", bt, "y") + not_bt = g.add("NotBT", "control::Not") + link(bt, "xey", not_bt, "in") + # no move in the scan right after a move, so every move is a separate edge for counting + not_am = g.add("NotAM", "control::Not") # in <- any-move (post, prev scan) + sh_en = g.add("ShEn", "control::And4") + link(shuf, "out", sh_en, "in1") + link(not_bt, "out", sh_en, "in2") + link(not_am, "out", sh_en, "in3") + not_rq = g.add("NotRq", "control::Not") # no move while the counter resets + link(shreq_p, "out", not_rq, "in") + link(not_rq, "out", sh_en, "in4") + tick = g.add("Tick", "control::TickTock", ticksPerSec=1) + step_os = g.add("Step", "control::OneShot", pulseWidth=0.05) # one scan per second + link(tick, "out", step_os, "in") + sv_en = g.add("SvEn", "control::And2") # in1 <- solving (post, prev scan) + link(step_os, "out", sv_en, "in2") + en = g.add("En", "control::Or2") + link(sh_en, "out", en, "in1") + link(sv_en, "out", en, "in2") + sel = g.add("Sel", "control::ASW") + link(neg_top, "out", sel, "in1") + link(code, "out", sel, "in2") + link(shuf, "out", sel, "s1") + pick, pick_slot, enable = sel, "out", en +else: + pick, pick_slot, enable = code, "out", shuf + +# tile folders are created in reverse order (15..1) so the suffix-sum chain settles in one scan +tf = {t: logic.add(f"t{t:02d}", "sys::Folder") for t in range(15, 0, -1)} +post = logic.add("post", "sys::Folder") + +# tile components are built first so the global sums (in glob) can link to their holds +tile_parts = {} +for t in range(15, 0, -1): + f = tf[t] + hc, hr = (t - 1) % N, (t - 1) // N + p = {} + p["ru"] = reset(f, "Ru", 0, 3, -hc, 3 - hc) # target du = cb - hc + p["rv"] = reset(f, "Rv", 0, 3, -hr, 3 - hr) # target dv = rb - hr + p["dc"] = f.add("Dc", "control::Sub2") # col - cb + p["dr"] = f.add("Dr", "control::Sub2") # 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") + if not SOLVER: + 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["du"] = f.add("Du", "control::ADemux2") + p["dv"] = f.add("Dv", "control::ADemux2") + p["xr"] = reset(f, "Xr", -3, 3, X0 + (hc - 3) * PITCH, X0 + (hc + 3) * PITCH) + p["yr"] = reset(f, "Yr", -3, 3, Y0 + (hr - 3) * PITCH, Y0 + (hr + 3) * PITCH) + p["xi"] = f.add("Xi", "control::F2I") + p["yi"] = f.add("Yi", "control::F2I") + p["q4"] = reset(f, "Q4", -3, 3, -12, 12) # 4*dv + p["t"] = f.add("T", "control::Add4") # suffix sum of (du + 4dv) + tile_parts[t] = p + +# global blank position (in glob folder, uses previous-scan holds) +su = sum_tree(g, "SU", [tile_parts[t]["du"] for t in range(1, 16)], "out2") +sv = sum_tree(g, "SV", [tile_parts[t]["dv"] for t in range(1, 16)], "out2") +cb = reset(g, "CB", -30, 30, 33, -27) # 3 - sum(du) +rb = reset(g, "RB", -30, 30, 33, -27) # 3 - sum(dv) +link(su, "out", cb, "in") +link(sv, "out", rb, "in") + +for t in range(15, 0, -1): + p = tile_parts[t] + tile = tiles[t] + link(cb, "out", p["ru"], "in") + link(rb, "out", p["rv"], "in") + link(p["du"], "out2", p["dc"], "in1") + link(p["ru"], "out", p["dc"], "in2") + link(p["dv"], "out2", p["dr"], "in1") + link(p["rv"], "out", p["dr"], "in2") + 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(pick, pick_slot, p["m"], "y") + link(p["m"], "xey", p["h"], "in1") + link(enable, "out", p["h"], "in2") + link(p["val"], "out", p["g"], "in1") + if SOLVER: # demo: tiles are driven by the solver only + link(p["h"], "out", p["g"], "in2") + else: + link(tile, "touch", p["p"], "in1") + link(p["h"], "out", p["p"], "in2") + link(p["p"], "out", p["g"], "in2") + link(p["g"], "out", p["os"], "in") + link(p["ru"], "out", p["du"], "in") + link(p["os"], "out", p["du"], "s1") + link(p["rv"], "out", p["dv"], "in") + link(p["os"], "out", p["dv"], "s1") + link(p["du"], "out2", p["xr"], "in") + link(p["dv"], "out2", p["yr"], "in") + link(p["xr"], "out", p["xi"], "in") + link(p["yr"], "out", p["yi"], "in") + link(p["xi"], "out", tile, "x") + link(p["yi"], "out", tile, "y") + link(p["dv"], "out2", p["q4"], "in") + link(p["du"], "out2", p["t"], "in1") + link(p["q4"], "out", p["t"], "in2") + if t < 15: + link(tile_parts[t + 1]["t"], "out", p["t"], "in3") + +# ---------------------------------------------------------------- post: win check + move counter +w = sum_tree(post, "W", [tile_parts[t]["t"] for t in range(1, 16)], "out") +w_ok = post.add("WOk", "ontrolControl::InRange", lowLimit=-0.5, highLimit=0.5) +link(w, "out", w_ok, "in") +bsum = post.add("BSum", "control::Add2") +link(cb, "out", bsum, "in1") +link(rb, "out", bsum, "in2") +b_ok = post.add("BOk", "ontrolControl::InRange", lowLimit=5.5, highLimit=6.5) +link(bsum, "out", b_ok, "in") +not_sh = post.add("NotSh", "control::Not") +link(shuf, "out", not_sh, "in") +solved = post.add("Solved", "control::And4") +link(w_ok, "out", solved, "in1") +link(b_ok, "out", solved, "in2") +link(not_sh, "out", solved, "in3") +link(true_c, "out", solved, "in4") +link(solved, "out", solved_lbl, "in") + +anym = or_tree(post, "AM", [tile_parts[t]["os"] for t in range(1, 16)], "out") +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") + +if SOLVER: + # stop the shuffle after SHUFFLE_MOVES moves + sh_cnt = post.add("ShCnt", "control::Count") + link(anym, "out", sh_cnt, "in") + link(true_c, "out", sh_cnt, "enable") + link(shreq_p, "out", sh_cnt, "r") + link(anym, "out", not_am, "in") + sh_cntf = post.add("ShCntF", "control::I2F") + link(sh_cnt, "out", sh_cntf, "in") + sh_done = post.add("ShDone", "ontrolControl::InRange", + lowLimit=SHUFFLE_MOVES - 0.5, highLimit=10000.0) + link(sh_cntf, "out", sh_done, "in") + link(sh_done, "out", shuf, "r") + # solving starts SOLVE_DELAY s after the shuffle ends + dly = post.add("Dly", "control::DlyOn", delayTime=SOLVE_DELAY) + link(not_sh, "out", dly, "in") + link(dly, "out", sv_en, "in1") + not_sv = post.add("NotSv", "control::Not") + link(solved, "out", not_sv, "in") + sv_lbl = post.add("SvLbl", "control::And2") + link(dly, "out", sv_lbl, "in1") + link(not_sv, "out", sv_lbl, "in2") + link(sv_lbl, "out", solving_lbl, "in") + + # move stack: r1 = top. Selectors all run before the registers, so every register + # latches its neighbour's previous value -> push shifts down, pop shifts up. + stk_s = logic.add("stkSel", "sys::Folder") + stk_r = logic.add("stkReg", "sys::Folder") + sels = [stk_s.add(f"S{i:02d}", "control::ASW") for i in range(1, STACK + 1)] + regs = [stk_r.add(f"R{i:02d}", "control::ADemux2") for i in range(1, STACK + 1)] + for i in range(STACK): + link(shuf, "out", sels[i], "s1") # true = push, false = pop + if i == 0: + link(sel, "out", sels[i], "in2") # push the executed code + else: + link(regs[i - 1], "out2", sels[i], "in2") + if i + 1 < STACK: + link(regs[i + 1], "out2", sels[i], "in1") # pop (last slot pulls in 0) + link(sels[i], "out", regs[i], "in") + link(anym, "out", regs[i], "s1") # latch on every executed move + empty = stk_r.add("Empty", "ontrolControl::InRange", lowLimit=-0.5, highLimit=0.5) + link(regs[0], "out2", empty, "in") + link(empty, "out", btn_ok, "in2") + link(regs[0], "out2", neg_top, "in") + +# ---------------------------------------------------------------- write +body = [] +for r in (service, driver, logic, ox): + body += r.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 new file mode 100644 index 0000000..3221525 --- /dev/null +++ b/verify_logic.py @@ -0,0 +1,350 @@ +"""Offline check of aspirin.sax logic: models the Sedona scan (children first, then +propagate links into a component, then execute) for the component types the app uses.""" +import random +import xml.etree.ElementTree as ET + +SCAN_MS = 100 + + +class C: + def __init__(self, typ, props): + self.typ = typ.split("::")[1] + self.v = {} + self.kids = [] + self.links_to = [] + self.state = {} + for k, val in props.items(): + if val in ("true", "false"): + self.v[k] = val == "true" + else: + try: + self.v[k] = float(val) + except ValueError: + self.v[k] = val + + def g(self, k, d=0.0): + return self.v.get(k, d) + + def start(self): + if self.typ == "Count": + self.v["out"] = self.g("preset", 0) + self.state["last"] = False + if self.typ in ("OneShot", "DlyOn"): + self.state["last"] = False + self.state["end"] = 0 + if self.typ == "SRLatch": + self.state["ls"] = self.state["lr"] = False + + def execute(self, now): + g, v, t = self.g, self.v, self.typ + if t in ("Add2", "Add4"): + v["out"] = g("in1") + g("in2") + g("in3") + g("in4") + elif t == "Sub2": + v["out"] = g("in1") - g("in2") + elif t == "Mul2": + v["out"] = g("in1") * g("in2") + elif t == "Reset": + i, a, b, c, d = g("in"), g("inMin"), g("inMax"), g("outMin"), g("outMax") + v["out"] = c if i <= a else d if i >= b else (d - c) / (b - a) * (i - a) + c + elif t == "Linearize": + xs = [g(f"x{i}") for i in range(10)] + ys = [g(f"y{i}") for i in range(10)] + out = float("nan") + i = g("in") + for k in range(9): + lo_ok = i >= xs[0] if k == 0 else i > xs[k] + if lo_ok and i <= xs[k + 1]: + m = (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k]) + out = m * i + (ys[k] - m * xs[k]) + v["out"] = out + elif t == "InRange": + v["out"] = g("lowLimit") <= g("in") <= g("highLimit") + elif t == "Cmpr": + v["xey"] = g("x") == g("y") + elif t == "And2": + v["out"] = bool(g("in1", False) and g("in2", False)) + elif t == "And4": + v["out"] = all(g(f"in{k}", False) for k in range(1, 5)) + elif t in ("Or2", "Or4"): + v["out"] = any(g(f"in{k}", False) for k in range(1, 5)) + elif t == "Not": + v["out"] = not g("in", False) + elif t == "OneShot": + i = g("in", False) + if i and not self.state["last"]: + if not g("out", False): + self.state["end"] = now + g("pulseWidth") * 1000 + v["out"] = self.state["end"] > now + self.state["last"] = i + elif t == "ADemux2": + if g("s1", False): + v["out2"] = g("in") + else: + v["out1"] = g("in") + elif t == "SRLatch": + if g("s", False) and not self.state["ls"]: + v["out"] = True + if g("r", False) and not self.state["lr"]: + v["out"] = False + self.state["ls"], self.state["lr"] = g("s", False), g("r", False) + elif t == "ASW": + v["out"] = g("in2") if g("s1", False) else g("in1") + elif t == "TickTock": + half = 500 / g("ticksPerSec", 1) + v["out"] = (now // half) % 2 == 0 + elif t == "DlyOn": + i = g("in", False) + if i and not self.state["last"]: + self.state["end"] = now + g("delayTime") * 1000 + if not i: + self.state["end"] = 0 + v["out"] = False + else: + v["out"] = self.state["end"] < now + if v["out"]: + self.state["end"] = 0 + self.state["last"] = i + elif t == "F2I": + v["out"] = float(int(g("in"))) + elif t == "I2F": + v["out"] = g("in") + elif t == "Count": + if not g("enable", False): + return + c = g("out") + if g("in", False) and not self.state["last"]: + c += 1 + if g("r", False): + c = g("preset", 0) + v["out"] = c + self.state["last"] = g("in", False) + + +def load(path): + root = ET.parse(path).getroot() + paths = {} + order = [] + + def walk(el, prefix): + comps = [] + for ce in el.findall("comp"): + props = {p.get("name"): p.get("val") for p in ce.findall("prop")} + c = C(ce.get("type"), props) + p = f"{prefix}/{ce.get('name')}" + paths[p] = c + c.kids = walk(ce, p) + comps.append(c) + return comps + + tops = walk(root.find("app"), "") + for le in root.find("links").findall("link"): + fp, fs = le.get("from").rsplit(".", 1) + tp, ts = le.get("to").rsplit(".", 1) + paths[tp].links_to.append((paths[fp], fs, ts)) + return tops, paths + + +def run_scan(tops, now): + def ex(c): + for k in c.kids: + ex(k) + for src, fs, ts in c.links_to: + c.v[ts] = src.v.get(fs, 0.0) + c.execute(now) + for t in tops: + ex(t) + + +def board(paths): + grid = [[" ."] * 4 for _ in range(4)] + for t in range(1, 16): + w = paths[f"/ox/Game/Tile{t:02d}"] + col = int((w.g("x") - 40) // 105) + row = int((w.g("y") - 40) // 105) + grid[row][col] = f"{t:3d}" + return grid + + +def main_solver(): + """aspirin_solver.sax: exact-length shuffle, then one inverse move per second until solved.""" + tops, paths = load("aspirin_solver.sax") + for c in paths.values(): + c.start() + now = 0 + goal = [[" 1", " 2", " 3", " 4"], [" 5", " 6", " 7", " 8"], + [" 9", " 10", " 11", " 12"], [" 13", " 14", " 15", " ."]] + snapshots, solve_times = [], [] + last = board(paths) + shuffle_done_at = None + for _ in range(1200): # 120 s + now += SCAN_MS + random.randint(-3, 3) + run_scan(tops, now) + b = board(paths) + shuffling = paths["/logic/glob/Shuf"].g("out", False) + if b != last and not shuffling and shuffle_done_at is not None: + solve_times.append(now) + if not shuffling and shuffle_done_at is None and now > 1000: + shuffle_done_at = now + snapshots.append(b) + print("after shuffle:") + print("\n".join(" ".join(r) for r in b)) + assert b != goal, "shuffle left board solved" + last = b + if shuffle_done_at and paths["/logic/post/Solved"].g("out", False): + break + assert paths["/logic/post/Solved"].g("out", False), "auto solver did not finish" + assert board(paths) == goal + gaps = [b - a for a, b in zip(solve_times, solve_times[1:])] + print(f"solved with {len(solve_times)} moves, first move {solve_times[0]-shuffle_done_at} ms " + f"after shuffle, gaps {min(gaps)}-{max(gaps)} ms, moves label " + f"{paths['/logic/post/CntF'].g('out')}") + assert len(solve_times) == 30 and min(gaps) >= 900 + assert paths["/logic/stkReg/Empty"].g("out", False) + # SHUFFLE again + paths["/ox/Game/Shuffle"].v["touch"] = True + for _ in range(3): + now += SCAN_MS + run_scan(tops, now) + paths["/ox/Game/Shuffle"].v["touch"] = False + for _ in range(600): + now += SCAN_MS + run_scan(tops, now) + if paths["/logic/post/Solved"].g("out", False) and not paths["/logic/glob/Shuf"].g("out", False): + break + assert board(paths) == goal and paths["/logic/post/Solved"].g("out", False) + print("second shuffle + auto solve OK") + + +def main(): + tops, paths = load("aspirin.sax") + for c in paths.values(): + c.start() + now = 0 + + def step(n=1): + nonlocal now + for _ in range(n): + now += SCAN_MS + random.randint(-3, 3) + run_scan(tops, now) + + def check_perm(): + cells = [tuple(r) for r in board(paths)] + flat = [x for r in cells for x in r] + nums = sorted(int(x) for x in flat if x.strip() != ".") + assert nums == list(range(1, 16)), cells + + step(120) # boot shuffle (10 s) + settle + check_perm() + g = board(paths) + print("after shuffle:") + print("\n".join(" ".join(r) for r in g)) + solved = paths["/logic/post/Solved"].g("out", False) + print("solved flag:", solved, "moves:", paths["/logic/post/CntF"].g("out")) + assert not solved + + def press(t): + paths[f"/ox/Game/Tile{t:02d}"].v["touch"] = True + step(2) + paths[f"/ox/Game/Tile{t:02d}"].v["touch"] = False + step(2) + + # solve by replaying moves with a simple BFS-free strategy: random legal presses until solved is + # impractical, so instead verify mechanics: invalid press does nothing, valid press swaps. + def blank(): + g = board(paths) + for r in range(4): + for c in range(4): + if g[r][c].strip() == ".": + return r, c + + def tile_at(r, c): + return int(board(paths)[r][c]) + + br, bc = blank() + # pick a non-adjacent tile + far = next((r, c) for r in range(4) for c in range(4) + if abs(r - br) + abs(c - bc) >= 2) + before = board(paths) + press(tile_at(*far)) + assert board(paths) == before, "non-adjacent tile moved" + moves0 = paths["/logic/post/CntF"].g("out") + # adjacent press + nr, nc = (br, bc - 1) if bc > 0 else (br, bc + 1) + t = tile_at(nr, nc) + press(t) + assert blank() == (nr, nc) and tile_at(br, bc) == t, "valid move failed" + assert paths["/logic/post/CntF"].g("out") == moves0 + 1 + # holding the finger must not oscillate + paths[f"/ox/Game/Tile{t:02d}"].v["touch"] = True + step(10) + paths[f"/ox/Game/Tile{t:02d}"].v["touch"] = False + step(2) + assert blank() == (br, bc), "hold did not move exactly once" + check_perm() + + # solve: undo using a solver on the current board (IDA* is overkill; use reverse of + # a fresh scramble instead): press SHUFFLE-free path -> brute force with BFS on small scramble + # Easier: drive tiles to solved using a known-solvable sequence from Python solver. + from collections import deque + + def state(): + g = board(paths) + return tuple(0 if x.strip() == "." else int(x) for r in g for x in r) + + goal = tuple(list(range(1, 16)) + [0]) + # A* with manhattan heuristic + import heapq + + def h(s): + d = 0 + for i, v in enumerate(s): + if v: + d += abs(i // 4 - (v - 1) // 4) + abs(i % 4 - (v - 1) % 4) + return d + + s0 = state() + pq = [(h(s0), 0, s0, [])] + seen = {s0: 0} + path = None + while pq: + f, gc, s, p = heapq.heappop(pq) + if s == goal: + path = p + break + 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 + [s[n]])) + if len(seen) > 2_000_000: + break + assert path is not None, "solver gave up" + print("solving in", len(path), "presses") + for t in path: + press(t) + step(25) + print("\n".join(" ".join(r) for r in board(paths))) + assert state() == goal + assert paths["/logic/post/Solved"].g("out", False), "solved flag not set" + print("SOLVED flag OK, moves:", paths["/logic/post/CntF"].g("out")) + + # SHUFFLE button re-scrambles and clears the flag/counter + paths["/ox/Game/Shuffle"].v["touch"] = True + step(2) + paths["/ox/Game/Shuffle"].v["touch"] = False + step(120) + check_perm() + assert state() != goal and not paths["/logic/post/Solved"].g("out", False) + assert paths["/logic/post/CntF"].g("out") == 0 + print("re-shuffle OK") + + +if __name__ == "__main__": + import sys + main_solver() if "--solver" in sys.argv else main() diff --git a/viewer/OxViewer.java b/viewer/OxViewer.java new file mode 100644 index 0000000..0a05f38 --- /dev/null +++ b/viewer/OxViewer.java @@ -0,0 +1,155 @@ +import java.awt.*; +import java.net.InetAddress; +import javax.swing.*; +import sedona.*; +import sedona.dasp.DaspSocket; +import sedona.sox.*; + +/** + * Live viewer for the aspirin apps running in the Ontrol simulator (headless SVM). + * Connects over SOX, polls /ox/Game widgets 5x per second and draws the 800x480 Orion page. + * Usage: java -cp ".;sedona.jar" OxViewer [host] [port] [user] [password] + */ +public class OxViewer extends JPanel { + static final String[] LABELS = {"Title", "Sub", "Moves", "Shuffle", "Busy", "Solving", "Solved"}; + SoxClient client; + SoxComponent page; + SoxComponent[] tiles = new SoxComponent[16]; + SoxComponent[] labels = new SoxComponent[LABELS.length]; + String lastBoard = ""; + + OxViewer(SoxClient client) throws Exception { + 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)); + for (int i = 0; i < LABELS.length; i++) labels[i] = load(page, LABELS[i]); + setPreferredSize(new Dimension(800, 480)); + } + + SoxComponent load(SoxComponent from, String... path) throws Exception { + SoxComponent c = from; + for (String p : path) { + if (c == null) return null; + client.update(c, 0x01); // tree + c = c.child(p); + } + if (c != null) client.update(c, 0x06); // config + runtime + return c; + } + + void poll() throws Exception { + for (SoxComponent c : tiles) 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)) { + System.out.println(b + " moves=" + str(labels[2], "in") + " " + status()); + System.out.flush(); + lastBoard = b; + } + } + + static Value val(SoxComponent c, String slot) { + Slot s = c.type.slot(slot); + return s == null ? null : c.get(s); + } + static int i(SoxComponent c, String slot) { return Integer.parseInt(val(c, slot).toString()); } + static String str(SoxComponent c, String slot) { + if (c == null) return ""; + Value v = val(c, slot); + if (v == null) return ""; + if (v instanceof Buf) { Buf b = (Buf) v; return new String(b.bytes, 0, b.size).replace("\0", ""); } + String s = v.toString(); + return s.endsWith(".0") ? s.substring(0, s.length() - 2) : s; + } + static Color c565(int v) { + v &= 0xFFFF; + return new Color(((v >> 11) & 31) * 255 / 31, ((v >> 5) & 63) * 255 / 63, (v & 31) * 255 / 31); + } + + String status() { + StringBuilder sb = new StringBuilder(); + for (int k = 4; k < LABELS.length; k++) + if (labels[k] != null && "true".equals(str(labels[k], "in"))) sb.append(str(labels[k], "trueText")).append(' '); + return sb.toString().trim(); + } + + String boardText() { + String[] cells = new String[16]; + java.util.Arrays.fill(cells, " ."); + 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))); + return sb.toString(); + } + + void box(Graphics2D g, SoxComponent w, String text, int fg, int bg, boolean bold, boolean fill) { + int x = i(w, "x"), y = i(w, "y"), wd = i(w, "width"), ht = i(w, "height"); + if (fill) { g.setColor(c565(bg)); g.fillRect(x, y, wd, ht); } + g.setColor(c565(fg)); + g.setFont(new Font("Tahoma", bold ? Font.BOLD : Font.PLAIN, Math.max(9, i(w, "fontSize") * 3 / 4))); + FontMetrics fm = g.getFontMetrics(); + g.drawString(text, x + (wd - fm.stringWidth(text)) / 2, y + (ht + fm.getAscent() - fm.getDescent()) / 2); + } + + @Override protected void paintComponent(Graphics g0) { + Graphics2D g = (Graphics2D) g0; + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g.setColor(c565(i(page, "backgroundColor"))); + g.fillRect(0, 0, 800, 480); + g.setColor(c565(0x31A6)); + g.fillRect(35, 35, 425, 425); + 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); + } + for (int k = 0; k < LABELS.length; k++) { + SoxComponent w = labels[k]; + if (w == null) continue; + String type = w.type.name; + if (type.equals("BooleanLabel")) { + boolean on = "true".equals(str(w, "in")); + box(g, w, str(w, on ? "trueText" : "falseText"), i(w, on ? "trueTextColor" : "falseTextColor"), + i(w, on ? "trueBackgroundColor" : "falseBackgroundColor"), true, true); + } else if (type.equals("NumericLabel")) { + String fmt = str(w, "text").replace("%.0f", str(w, "in")); + box(g, w, fmt, i(w, "textColor"), i(w, "backgroundColor"), false, true); + } else { + box(g, w, str(w, "text"), i(w, "textColor"), i(w, "backgroundColor"), i(w, "fontAttributes") == 1, true); + } + } + } + + public static void main(String[] a) throws Exception { + String host = a.length > 0 ? a[0] : "127.0.0.1"; + int port = a.length > 1 ? Integer.parseInt(a[1]) : 1876; + String user = a.length > 2 ? a[2] : "guest"; + String pass = a.length > 3 ? a[3] : ""; + DaspSocket sock = DaspSocket.open(-1, null, DaspSocket.SESSION_QUEUING); + OxViewer v = null; + while (v == null) { // wait for the simulator to come up + try { + SoxClient client = new SoxClient(sock, InetAddress.getByName(host), port, user, pass); + client.connect(); + v = new OxViewer(client); + } catch (Exception e) { + System.out.println("waiting for SOX " + host + ":" + port + " (" + e + ")"); + Thread.sleep(500); + } + } + JFrame f = new JFrame("Orion Screen - aspirin (live via SOX " + host + ":" + port + ")"); + f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + f.add(v); + f.pack(); + f.setLocation(60, 60); + f.setVisible(true); + while (true) { + v.poll(); + v.repaint(); + Thread.sleep(200); + } + } +} diff --git a/viewer/run_viewer.cmd b/viewer/run_viewer.cmd new file mode 100644 index 0000000..224eb6c --- /dev/null +++ b/viewer/run_viewer.cmd @@ -0,0 +1,9 @@ +@echo off +rem Live Orion screen for the aspirin apps running in simulatorLauncher (SOX 127.0.0.1:1876, guest). +rem Start it before or after "Start Simulator"; it waits for the simulator to come up. +setlocal +set "J=C:\Niagara\Niagara-4.15.1.16\jre\bin" +set "S=C:\Users\aa\OneDrive - Ontrol\Documents\_is\NiagaraAXSedona\sedona" +cd /d "%~dp0" +if not exist OxViewer.class "%J%\javac.exe" -cp "%S%\lib\sedona.jar" OxViewer.java +"%J%\java.exe" "-Dsedona.home=%S%" -cp ".;%S%\lib\sedona.jar" OxViewer %*