Compare commits

..
Author SHA1 Message Date
Stefan MaierandClaude Opus 5.5 7c294c9c39 Merge OpenCE upstream: its fix of the string conversion (taken over ours), voice packets checked, the postgame host screen's menu
Upstream fixed the Android asm conversion's string bug independently
(d9669f6ee, also from a Steam Frame crash); its converter and tests
replace ours (c85cf8ece), which they cover and extend (quoted names
honour escapes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:05:24 +02:00
bnunu f90d955527 Merge pull request #374 from chestahh/opence-multiplayer-menu-fix
Restore the main menu when leaving the postgame host screen
2026-10-16 00:13:42 -04:00
bnunu 085676d827 Merge pull request #346 from startupfoundry/arm64-guest-strings
arm64 and Android guests: string literals kept byte for byte (fixes a voice chat crash)
2026-10-15 23:37:14 -04:00
chestahh 4301ca88d6 Restore the main menu when leaving the postgame host screen
The postgame host map picker opens without menu history. Give it the
existing main_menu_if_no_history flag used by the lobby so leaving the
last screen restores the main menu instead of leaving only the background.
Update the generator and generated XML together. Preserve OpenCE's
existing handlers; no MCC code or regression-test files are included.
This local menu change does not require a network version bump.

Resolved the fork cherry-pick against OpenCE main 8102e711 by adding only
the flag override. Windows portable release build passed. Existing menu
teardown, touch and web checks returned 28 passes and 6 failures: missing
m.lib/pthread.h and Windows path separators in POSIX-oriented tests.
Android and web build targets were unavailable locally. Checked generator
round-trip stability and git diff --check. The two simple wrappers were
regenerated through ce_menus.widget_xml from their exported XML fields;
the original PC tag inputs were unavailable. No upstream runtime game
session or Linux build was run. Earlier scripted runtime verification
was performed on the GulchCE implementation, not this upstream revision.

(cherry picked from commit c811c7f527d70d5476578ed88ea0edcef2269967)
2026-10-16 02:51:54 +01:00
Paul Hontz 9c70f5f5e3 Voice chat: a packet that is not one 20 ms frame is dropped before decoding
Every build sends one 20 ms frame per packet (voice_audio_encode). A packet
whose table of contents says otherwise, or that Opus cannot parse, is
damaged: voice_audio_play now drops it before the decoder sees it
(opus_packet_get_nb_samples), and the next frame makes up for it as lost.
Cheap defence in depth: Opus's decoder already refuses such packets, and the
crash in decode_pulses came from a valid packet and the guest's garbled
pulse cache, fixed in the assembly conversion.
2026-10-13 22:13:44 -04:00
Paul Hontz d9669f6eef The guest's assembly conversion keeps string literals byte for byte
The arm64 and Android guests are compiled for arm64_32-apple-watchos and
their Darwin assembly converted to ELF (tools/android_asm_convert.py). The
converter took a quoted .ascii/.asciz operand for a quoted symbol name and
dropped a leading "_" from it, as it does from symbols; and its quoted-name
pattern ended at an escaped quote (\"), so the "_" of a word after one was
dropped too. Clang writes byte tables as .ascii strings, so a table holding
95 ("_") lost that byte and everything after it moved down by one.

In Opus that table was CELT's pulse cache (modes.c, cache_bits50): the bits
a band may have are read off it, and shifted by a byte it lets a band have
more pulses than the PVQ tables hold. Decoding a frame from another player
then read past CELT_PVQ_U_ROW in decode_pulses (n 32, k 240) and crashed: a
client on the Steam Frame, in a CTF game on the internet. The guest's own
encoder could crash likewise in encode_pulses. The same build of Opus,
driven with the game's settings and with clean, corrupt, truncated and
random packets and voice_audio_play's loss handling, crashed within a few
thousand frames (and identically at -O0); with the fix it ran 4.8 million
decodes clean, and its encoded bytes and decoded samples are identical to a
native 64-bit build's.

The same loss hit other strings and tables of the guest: musl's iswalpha,
iswpunct and towctrans tables and iconv's charmaps; tomlc17's "_0123..."
digit sets (TOML numbers with "_"); the attract mode's "_de"/"_fr" names,
"_lan_", "_lit" and "_%s"; the progress bar's compressed monk tone and the
DirectSound effects image; and assertion and error texts. All are now
assembled as compiled.

String directives now go through unchanged, and quoted names honour
escapes. tools/test_android_asm_convert.py checks both, and a table of
every byte value through clang, the conversion and the assembler.
2026-10-13 22:13:44 -04:00
8 changed files with 88 additions and 30 deletions

No files matched your search

+1 -1
View File
@@ -99,7 +99,7 @@ jobs:
- name: Test
run: |
python -m pytest -q tools/test_linux_port.py tools/test_host_watch_hash.py tools/test_touch_menu.py tools/harness tools/test_profile.py
python -m pytest -q tools/test_linux_port.py tools/test_android_asm_convert.py tools/test_host_watch_hash.py tools/test_touch_menu.py tools/harness tools/test_profile.py
python tools/test_light_storage.py
- name: Compile cache
+3 -1
View File
@@ -388,7 +388,9 @@ The guest is the game, the platform layer and a small runtime:
AArch64 target of clang. The options `-U__APPLE__` and
`-fno-define-target-os-macros` hide the Darwin environment.
2. `tools/android_asm_convert.py` changes the Mach-O assembly to ELF
assembly.
assembly. It renames symbols, but keeps string literals (`.ascii`,
`.asciz`) byte for byte: they hold tables as well as text
(`tools/test_android_asm_convert.py`).
3. The AArch64 assembler makes the objects.
4. `ld.lld` links the objects with `guest/guest.ld` to a static image,
`build/android/halo_guest.elf`, at a fixed address above the Xbox memory
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- The PC version's menus: ui\shell\main_menu\multiplayer_type_select\connected (tools/ce_menus.py) -->
<menus>
<widget name="main_menu/multiplayer_type_select/connected/connected_map_select_wrapper" width="640" height="480" flags="pass_unhandled_to_focused_child pause_game tag_controller_index force_handle_mouse">
<widget name="main_menu/multiplayer_type_select/connected/connected_map_select_wrapper" width="640" height="480" flags="pass_unhandled_to_focused_child pause_game main_menu_if_no_history tag_controller_index force_handle_mouse">
<child widget="main_menu/multiplayer_type_select/mp_map_select/mp_map_select_screen"/>
</widget>
<widget name="main_menu/multiplayer_type_select/connected/gametype_select_screen_wrapper" width="640" height="480" flags="pass_unhandled_to_focused_child pause_game">
+4
View File
@@ -395,6 +395,10 @@ void voice_audio_play(int speaker, unsigned short sequence, const unsigned char
if (length <= 0 || length > VOICE_MAXIMUM_PACKET)
return;
/* (every build sends a frame of 20 ms: anything else, damaged, is
dropped before the decoder sees it, and made up for as lost) */
if (opus_packet_get_nb_samples(packet, length, VOICE_RATE) != VOICE_FRAME_SAMPLES)
return;
pthread_mutex_lock(&voice_lock);
slot = voice_slot_for(speaker, TRUE);
if (!slot)
+8 -5
View File
@@ -15,7 +15,13 @@ Usage: android_asm_convert.py input.s output.s
import re
import sys
IDENT = re.compile(r'"[^"]*"|[A-Za-z_.$][\w.$]*')
# a quoted name (its escapes kept: \" does not end it) or a plain one
IDENT = re.compile(r'"(?:[^"\\]|\\.)*"|[A-Za-z_.$][\w.$]*')
# directives whose operands are string literals, the same in both
# syntaxes: they go through as they are, never renamed as symbols (data
# starting with "_" would lose that byte)
STRING_DIRECTIVES = {".ascii", ".asciz", ".string"}
DROP_DIRECTIVES = {
".subsections_via_symbols", ".build_version", ".watchos_version_min",
@@ -217,10 +223,7 @@ class Converter:
if op == ".align":
self.emit(f"\t.p2align {args}")
return
if op in (".ascii", ".asciz", ".string"):
# string data, kept as it is: no symbol names in it (renamed, a
# "_" at the start of a word was dropped: a string with \" in it
# went wrong, Opus's tables among them)
if op in STRING_DIRECTIVES:
self.emit(f"\t{op} {args}")
return
if op.startswith("."):
+1 -1
View File
@@ -22,7 +22,7 @@ no files beside its guest image.
The data are 32-bit words, not bytes: the Android build passes the guest's
assembly through tools/android_asm_convert.py, which rewrites identifiers in
operands and would garble a long .ascii string of binary data.
operands (it keeps .ascii strings as they are, but once garbled them).
"""
import json
+5
View File
@@ -618,6 +618,11 @@ MAP_KIND_CHOOSER = "main_menu/new_select/list_item_0_map_kind"
# changes to the PC version's widgets (by our names): attributes set, all
# their handlers replaced, children added, game data inputs added
WIDGET_PATCHES = {
# The host returns here after a round with no menu history. Back must
# reopen the main menu when it closes the last screen, as the lobby does.
f"{MT}/connected/connected_map_select_wrapper": {"set": {
"flags": "pass_unhandled_to_focused_child pause_game main_menu_if_no_history tag_controller_index force_handle_mouse",
}},
# (the profile settings' picture: on Gamepad Setup's row, the profile's
# button settings, BITMAP_FRAMES; menu_functions.c's
# profile_gamepad_layout)
+65 -21
View File
@@ -1,32 +1,76 @@
"""Tests of tools/android_asm_convert.py, the Mach-O to ELF assembly
conversion of the Android build's guest code."""
"""The Darwin to ELF assembly conversion of the ILP32 guest
(tools/android_asm_convert.py): string literals keep every byte. It once took
a quoted .ascii operand for a quoted symbol name and dropped its leading "_"
(and, after an escaped quote, the "_" of any word after it), which shifted
tables such as Opus's pulse cache by a byte: voice chat crashed in
decode_pulses on the arm64 and Android builds."""
import shutil
import struct
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import pytest
from android_asm_convert import Converter # noqa: E402
from tools.android_asm_convert import Converter
ROOT = Path(__file__).resolve().parent.parent
def convert(*lines):
return Converter(list(lines)).run().split("\n")
return Converter(list(lines)).run().splitlines()
def test_symbols_lose_their_underscore():
out = convert("_main:", "\tbl\t_foo", "\t.long\t_bar")
assert out[:3] == ["main:", "\tbl foo", "\t.long bar"]
@pytest.mark.parametrize("directive", [
'.ascii\t"_c[}]L{s{"',
'.asciz\t"_transport_error_none"',
'.ascii\t"v\\177\\"O7v\\177_\\037\\374"',
'.ascii\t"=`\'\\\\_\\026"',
'.asciz\t"ltmp0 _x @PAGE ; not a comment"',
])
def test_string_literals_are_kept(directive):
op, args = directive.split("\t", 1)
assert convert("l_.str:", directive) == [".Ll_.str:", f"\t{op} {args}"]
def test_strings_are_kept():
# (a string starting with "_", or one with \" in it, lost a "_": Opus's
# pulse cache was one byte short, and its encoder crashed)
out = convert(
'\t.asciz\t"_lan_"',
'\t.ascii\t"\\"$&\'\\0063_\\206"',
'\t.string\t"l_.str _foo"',
)
assert out[:3] == [
'\t.asciz "_lan_"',
'\t.ascii "\\"$&\'\\0063_\\206"',
'\t.string "l_.str _foo"',
]
def test_symbols_are_still_renamed():
assert convert("\t.globl\t_table", "_table:", "\tadrp\tx8, _table@PAGE",
"\tadd\tx8, x8, _table@PAGEOFF", '\t.long\t"_odd name"') == [
"\t.globl table", "table:", "\tadrp x8, table", "\tadd x8, x8, :lo12:table", '\t.long "odd name"']
def rodata(elf: bytes) -> bytes:
"""the .rodata section of a 64-bit little-endian ELF object"""
shoff, = struct.unpack_from("<Q", elf, 0x28)
shentsize, shnum, shstrndx = struct.unpack_from("<HHH", elf, 0x3a)
sections = [struct.unpack_from("<IIQQQQ", elf, shoff + index * shentsize) for index in range(shnum)]
names = sections[shstrndx][4]
for name, _, _, _, offset, size in sections:
if elf[names + name:elf.index(b"\0", names + name)] == b".rodata":
return elf[offset:offset + size]
raise AssertionError("no .rodata")
def test_tables_survive_the_build(tmp_path):
"""every byte value, and the patterns that were lost, through the
guest's pipeline (clang for arm64_32, the conversion, the assembler)"""
clang = shutil.which("clang")
if not clang:
pytest.skip("no clang")
table = bytes(range(256)) + b'_a"_b\\_c' + bytes(reversed(range(256)))
source = tmp_path / "table.c"
source.write_text(
"const unsigned char table[] = {" + ",".join(str(b) for b in table) + "};\n"
'const char text[] = "_starts with an underscore";\n')
darwin, converted, obj = tmp_path / "table.darwin.s", tmp_path / "table.s", tmp_path / "table.o"
try:
subprocess.run([clang, "--target=arm64_32-apple-watchos", "-O2", "-S", str(source), "-o", str(darwin)],
check=True, capture_output=True)
except subprocess.CalledProcessError:
pytest.skip("clang has no arm64_32 target")
subprocess.run([sys.executable, str(ROOT / "tools" / "android_asm_convert.py"), str(darwin), str(converted)],
check=True)
subprocess.run([clang, "--target=aarch64-linux-gnu", "-c", str(converted), "-o", str(obj)], check=True)
data = rodata(obj.read_bytes())
assert table in data
assert b"_starts with an underscore\0" in data