Fix updater branch status messages and Skyline remote VFX init.

Branch channel now reports already-current and missing-release cases clearly instead of a generic 404 error, and remote smoke/skids initialize without requiring local F10 spawn.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Ethanw05andCursor committed 2026-09-12 01:30:05 -06:00
1 parent 3dd61acc62
commit 9eaabf198e
3 files changed
+182 -85

No files matched your search

+56 -7
View File
@@ -64,7 +64,7 @@ local state = {
steer=0, shift=0, time=0, enter_after=0,
keys={}, buttons=0, hud_time=0, mass_audit=nil, keyboard_axis=0, aero_load=0,
enter_requested=false, exit_requested=false, reenter_on_ready=false,
hull_debug=false, interaction_text="",
hull_debug=false, interaction_text="", mp_hint_time=0,
}
local function clamp(x,a,b) return math.max(a,math.min(b,x)) end
@@ -388,6 +388,35 @@ local REMOTE_WHEEL_KEYS={rr="wheel_rr",rl="wheel_rl"}
local function vfx_net_active()
return sdk.net and type(sdk.net.info)=="function" and (sdk.net.info().active==true)
end
local function refresh_mp_status(dt)
if not sdk.net or type(sdk.net.info)~="function" then
sdk.ui.text("skyline_mp","")
return
end
local info=sdk.net.info()
if not info or info.active~=true then
sdk.ui.text("skyline_mp","")
return
end
state.mp_hint_time=math.max(0,(state.mp_hint_time or 0)-dt)
if state.mp_hint_time>0 then return end
state.mp_hint_time=0.5
local lines={
state.spawned
and "Your car is spawned and should replicate to other players."
or "Press F10 to spawn your car. Other players cannot see your vehicle until you do.",
"Both players need Skyline enabled with identical mod files and the skyline-driving-update build.",
}
if vfx.supported then
lines[#lines+1]=vfx.mesh
and "VFX buffers ready (remote smoke/skids work without spawning your own car)."
or "VFX supported but buffers not ready yet."
else
lines[#lines+1]="VFX unsupported: update to a build with mesh_buffer (graphics API 4)."
end
if info.status and info.status~="" then lines[#lines+1]=info.status end
sdk.ui.text("skyline_mp",table.concat(lines,"\n"))
end
local function clear_mesh(mesh)
mesh.positions={};mesh.normals={};mesh.colors={};mesh.uvs={};mesh.indices={}
end
@@ -995,11 +1024,28 @@ local function tick_remote_vfx(dt)
end
maybe_upload_skids()
end
local function prepare_effects()
local function refresh_vfx_support()
vfx.supported=sdk.graphics and (sdk.graphics.version or 0)>=4
and type(sdk.graphics.mesh_buffer)=="function"
and type(sdk.graphics.mesh_buffer_append)=="function"
if not vfx.supported then return end
return vfx.supported
end
local function ensure_vfx_mesh()
if not refresh_vfx_support() then return false end
if vfx.mesh then return true end
vfx.smoke=vfx.smoke or {}
vfx.remote_smoke=vfx.remote_smoke or {}
vfx.remote_wheels=vfx.remote_wheels or {}
vfx.mesh={
smoke={positions={},normals={},colors={},uvs={},indices={}},
remote_smoke={positions={},normals={},colors={},uvs={},indices={}},
}
sdk.graphics.mesh_buffer("vfx_smoke",SMOKE_BUFFER_OPTS)
sdk.graphics.mesh_buffer("vfx_smoke_remote",SMOKE_BUFFER_OPTS)
return true
end
local function prepare_effects()
if not refresh_vfx_support() then return end
clear_session_skids()
vfx.skid_buffer_serial=0
vfx.smoke={}; vfx.smoke_mesh_live=false; vfx.smoke_dirty=false
@@ -1048,7 +1094,7 @@ local function tick_wheel_vfx(contacts,dt)
publish_net_vfx(contacts)
end
local function upload_wheel_vfx(event)
if not vfx.supported or not vfx.mesh then return end
if not ensure_vfx_mesh() then return end
if sdk.snapshot.paused or sdk.snapshot.replay then return end
local dt=clamp(number(event and event.dt,1/60),0,0.1)
tick_remote_vfx(dt)
@@ -1777,13 +1823,14 @@ return {
if not sdk.graphics or (sdk.graphics.version or 0)<2 or not sdk.player.detaching then
error("Skyline 4.3.1: the embedded Lua SDK wrapper does not match the native Model Collision Repair API")
end
reset_simulation(); remove_rig(); prepare_audio(); prepare_presentation()
reset_simulation(); remove_rig(); prepare_audio(); prepare_presentation(); ensure_vfx_mesh()
set_collision_debug(sdk.settings.show_collision_hull==true)
sdk.log("Skyline 4.3.1: render-model compound collision, native impacts, animated wheels, safe exits; J toggles actual colliders")
sdk.ui.text("skyline_status",""); sdk.ui.text("skyline_handling",""); sdk.ui.text("skyline_wheels","")
end,
on_update=function(event)
update_audio(event); update_dashboard(event); upload_wheel_vfx(event)
refresh_mp_status(clamp(number(event and event.dt,1/60),0,0.1))
end,
on_settings=function(event)
if event.key=="audio_enabled" and event.value==false then stop_audio() end
@@ -1800,6 +1847,7 @@ return {
clear_session_skids()
sdk.ui.text("skyline_audio",""); sdk.ui.text("skyline_presentation","")
sdk.ui.text("skyline_help",""); sdk.ui.text("skyline_status",""); sdk.ui.text("skyline_handling",""); sdk.ui.text("skyline_wheels","")
sdk.ui.text("skyline_mp","")
end,
on_fixed_update=function(event)
local dt=number(event and event.dt,1/120)
@@ -1854,13 +1902,14 @@ return {
end,
on_event=function(event)
if event.name=="world_changed" then
stop_audio(); clear_presentation(); clear_effects(); clear_session_skids(); sound.prepared=false; prepare_audio()
state.spawned=false; state.occupied=false; state.pending=nil
remove_rig(); stop_audio(); clear_presentation(); sound.prepared=false; prepare_audio()
state.pending=nil
state.enter_requested=false; state.exit_requested=false; state.reenter_on_ready=false
state.enter_after=state.time+C.reentry_delay; interaction_text("")
set_collision_debug(state.hull_debug)
state.keys={}; state.buttons=0; reset_simulation()
sdk.ui.text("skyline_status",""); sdk.ui.text("skyline_handling",""); sdk.ui.text("skyline_wheels","")
sdk.ui.text("skyline_mp","")
end
end,
}
+43 -33
View File
@@ -150,6 +150,13 @@ class UpdaterTests(unittest.TestCase):
redirected = u.DownloadRedirect().redirect_request(req, None, 302, 'Found', {}, 'https://release-assets.githubusercontent.com/file')
self.assertIsNone(redirected.get_header('Authorization'))
def test_branch_choice_fallback(self):
with patch.object(u, 'list_branches', side_effect=OSError('offline')):
names = u.branch_choices(threading.Event(), 'feature/foo')
self.assertEqual(names[0], 'feature/foo')
self.assertIn('skyline-driving-update', names)
self.assertIn('main', names)
def test_github_api_token_on_actions_requests(self):
with patch.dict(os.environ, {'SKATE_UPDATE_GITHUB_TOKEN': 'test-token'}, clear=False):
headers = u.github_request_headers(u.BRANCHES_API)
@@ -198,51 +205,54 @@ class UpdaterTests(unittest.TestCase):
u.stage(candidate, Path(temp), threading.Event(), lambda _: None)
self.assertEqual((Path(temp) / 'new/skate3rust.exe').read_bytes(), b'new')
def test_branch_discovery_and_stage(self):
meta = {**metadata(9), 'tag': 'experimental', 'revision': 'b' * 40}
meta['files'] = {name: hashlib.sha256(b'new').hexdigest() for name in u.FILES[:-1]}
package = u.package_name(meta)
archive = io.BytesIO()
with zipfile.ZipFile(archive, 'w') as inner:
for name in [*meta['files'], 'release.json']:
inner.writestr(u.PREFIX + name, json.dumps(meta) if name == 'release.json' else b'new')
package_bytes = archive.getvalue()
digest = hashlib.sha256(package_bytes).hexdigest()
artifact = io.BytesIO()
with zipfile.ZipFile(artifact, 'w') as outer:
outer.writestr(package, package_bytes)
outer.writestr(package + '.sha256', f'{digest} {package}')
outer.writestr('release.json', json.dumps(meta))
artifact_bytes = artifact.getvalue()
def test_branch_release_status_messages(self):
meta = {**metadata(12), 'tag': 'skyline-driving-update', 'revision': 'd' * 40}
release = dict(
id=90,
tag_name='skyline-driving-update',
draft=False,
prerelease=True,
assets=[
dict(name=u.PACKAGE, state='uploaded', browser_download_url='package'),
dict(name=u.PACKAGE + '.sha256', state='uploaded', browser_download_url='checksum'),
dict(name='release.json', state='uploaded', browser_download_url='manifest'),
],
)
def fetch(url, *args):
if url.endswith('/tags/skyline-driving-update'):
return json.dumps(release).encode()
if url.endswith('/tags/main'):
raise u.urllib.error.HTTPError(url, 404, 'not found', {}, None)
if url == 'manifest':
return json.dumps(meta).encode()
raise AssertionError(url)
current = {**metadata(12), 'revision': meta['revision']}
with patch.object(u, 'fetch', fetch):
message, revision = u.branch_release_status(current, 'skyline-driving-update', threading.Event())
self.assertIn('already installed', message)
self.assertEqual(revision, meta['revision'])
message, revision = u.branch_release_status(current, 'main', threading.Event())
self.assertIn('no published installer', message.lower())
self.assertIsNone(revision)
def test_branch_discovery_without_published_release(self):
def fetch(url, *args):
if '/releases/tags/' in url:
raise u.urllib.error.HTTPError(url, 404, 'not found', {}, None)
if url.startswith(u.BRANCHES_API):
return json.dumps([{'name': 'main'}, {'name': 'skyline'}]).encode()
if '/workflows/' in url and '/runs?' in url:
return json.dumps({'workflow_runs': [
{'id': 77, 'conclusion': 'success', 'head_sha': meta['revision']},
]}).encode()
if url.endswith('/artifacts'):
return json.dumps({'artifacts': [
{'name': u.ARTIFACT_NAME, 'archive_download_url': 'https://api.github.com/repos/' + u.REPO + '/actions/artifacts/1/zip'},
]}).encode()
if url.endswith('/artifacts/1/zip'):
return artifact_bytes
raise AssertionError(url)
current = metadata(8)
with patch.object(u, 'fetch', fetch):
names = u.list_branches(threading.Event())
self.assertIn('skyline', names)
candidate = u.discover_branch(current, 'skyline', threading.Event())
self.assertIsNotNone(candidate)
self.assertEqual(candidate[0], 9)
with tempfile.TemporaryDirectory() as temp:
u.stage(candidate, Path(temp), threading.Event(), lambda _: None)
self.assertEqual((Path(temp) / 'new/skate3rust.exe').read_bytes(), b'new')
self.assertIsNone(u.discover_branch({**current, 'revision': meta['revision']}, 'skyline', threading.Event()))
self.assertIsNone(u.discover_branch(current, 'skyline', threading.Event()))
message, revision = u.branch_release_status(current, 'skyline', threading.Event())
self.assertIn('No published release', message)
self.assertIsNone(revision)
if __name__ == '__main__':
+83 -45
View File
@@ -27,6 +27,7 @@ FILES = ('skate3rust.exe', 'support/skate3setup.exe', 'support/skate3update.exe'
'steam-relay/skate-steam-relay.exe', 'steam-relay/steam_api64.dll', 'release.json')
PREFIX = 'skate3rust-windows-x64/'
DEFAULT_BRANCH_CHOICES = ('skyline-driving-update', 'main')
class DownloadRedirect(urllib.request.HTTPRedirectHandler):
@@ -132,6 +133,8 @@ def fetch(url, cancel, limit, progress=lambda value: None):
if error.code in (403, 429):
raise ValueError('GitHub rate limit or access restriction; try again later') from error
if error.code == 404:
if '/releases/tags/' in url:
raise
raise ValueError('Releases are not publicly available, or private-release access is missing') from error
raise
with response:
@@ -170,6 +173,17 @@ def release_candidates(assets, rolling):
return [(f'release-{n}.json', f'skate3rust-windows-x64-build-{n}.zip') for n in builds]
def merge_branch_names(names, preferred=''):
values = [name for name in (names or DEFAULT_BRANCH_CHOICES) if isinstance(name, str) and name]
preferred = (preferred or '').strip()
if preferred and preferred not in values:
values.insert(0, preferred)
for fallback in DEFAULT_BRANCH_CHOICES:
if fallback not in values:
values.append(fallback)
return values
def list_branches(cancel, limit=200):
branches = []
for page in range(1, 6):
@@ -182,6 +196,13 @@ def list_branches(cancel, limit=200):
return branches[:limit]
def branch_choices(cancel, preferred=''):
try:
return merge_branch_names(list_branches(cancel), preferred)
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError, json.JSONDecodeError, OSError):
return merge_branch_names([], preferred)
def branch_workflow_runs(branch, cancel):
from urllib.parse import quote
query = (
@@ -215,7 +236,7 @@ def package_from_artifact(artifact_zip, package):
return payload, checksum
def discover_branch_release(current, branch, cancel, repair=False):
def branch_release_meta(branch, cancel):
from urllib.parse import quote
branch = (branch or '').strip()
try:
@@ -230,18 +251,40 @@ def discover_branch_release(current, branch, cancel, repair=False):
return None
try:
meta = json.loads(fetch(asset_url(assets['release.json']), cancel, 65536))
build = identity(meta)
identity(meta)
program_metadata(meta)
except (ValueError, KeyError):
except (ValueError, KeyError, urllib.error.HTTPError):
return None
return release, assets, meta
def discover_branch_release(current, branch, cancel, repair=False):
loaded = branch_release_meta(branch, cancel)
if loaded is None:
return None
release, assets, meta = loaded
if meta['revision'] == current.get('revision') and not repair:
return None
notes = dict(
tag_name=branch,
body=(f'Branch `{branch}`\n'
f'Build {build} · revision {meta.get("revision", "")[:12]}'),
f'Build {meta["build"]} · revision {meta.get("revision", "")[:12]}'),
)
return (build, release['id'], notes, assets, meta)
return (meta['build'], release['id'], notes, assets, meta)
def branch_release_status(current, branch, cancel):
loaded = branch_release_meta(branch, cancel)
if loaded is None:
branch = (branch or '').strip()
if branch == 'main':
return ('Branch `main` has no published installer. Use `skyline-driving-update`.', None)
return (f'No published release found for branch `{branch}`.', None)
_, _, meta = loaded
revision = meta.get('revision', '')
if revision and revision == current.get('revision'):
return (f'Branch `{branch}` build {meta["build"]} is already installed ({revision[:12]}).', revision)
return (None, revision)
def discover_branch(current, branch, cancel, repair=False):
@@ -251,32 +294,6 @@ def discover_branch(current, branch, cancel, repair=False):
candidate = discover_branch_release(current, branch, cancel, repair=repair)
if candidate is not None:
return candidate
current_revision = current.get('revision')
for run in branch_workflow_runs(branch, cancel):
if run.get('conclusion') != 'success':
continue
try:
artifact_zip = download_branch_artifact(run['id'], cancel)
import io
with zipfile.ZipFile(io.BytesIO(artifact_zip)) as archive:
if 'release.json' not in {info.filename for info in archive.infolist()}:
continue
meta = json.loads(archive.read('release.json'))
build = identity(meta)
program_metadata(meta)
if meta['revision'] == current_revision and not repair:
continue
package = package_name(meta)
package_from_artifact(artifact_zip, package)
notes = dict(
tag_name=meta.get('tag', branch),
body=(f'Branch `{branch}`\n'
f'Workflow run {run.get("id")} @ {run.get("head_sha", "")[:12]}\n'
f'Build {build} · revision {meta.get("revision", "")[:12]}'),
)
return (build, run['id'], notes, None, meta, artifact_zip)
except (ValueError, KeyError, zipfile.BadZipFile):
continue
return None
@@ -463,8 +480,23 @@ def main(request=None):
def branch_ui_enabled():
return channel.get() == 'Branch'
def apply_branch_choices(names):
branch_choice.configure(values=merge_branch_names(names, branch.get().strip()))
def refresh_branch_ui():
branch_choice.configure(state='normal' if branch_ui_enabled() else 'disabled')
enabled = branch_ui_enabled()
branch_choice.configure(state='normal' if enabled else 'disabled')
if enabled:
apply_branch_choices(branch_choice.cget('values') or DEFAULT_BRANCH_CHOICES)
def load_branch_list():
token = generation
def run():
try:
events.put((token, 'branches', branch_choices(cancel, branch.get().strip())))
except Exception:
events.put((token, 'branches', merge_branch_names([], branch.get().strip())))
threading.Thread(target=run, daemon=True).start()
def work(kind, operation):
nonlocal busy
@@ -512,12 +544,12 @@ def main(request=None):
repair_after_update = (bool(repair_files) and isinstance(previous.get('build'), int)
and previous['build'] < current['build'])
if selected == 'Branch':
names = list_branches(cancel)
selected_branch = branch.get().strip()
if selected_branch and selected_branch not in names:
names.insert(0, selected_branch)
return names, discover_branch(current, selected_branch, cancel, repair=bool(repair_files))
return None, discover(current, selected, cancel, repair=bool(repair_files))
selected_branch = branch.get().strip() or DEFAULT_BRANCH_CHOICES[0]
names = branch_choices(cancel, selected_branch)
candidate = discover_branch(current, selected_branch, cancel, repair=bool(repair_files))
branch_status = branch_release_status(current, selected_branch, cancel) if candidate is None else None
return names, candidate, branch_status
return None, discover(current, selected, cancel, repair=bool(repair_files)), None
work('checked', perform_check)
def do_update():
@@ -552,10 +584,14 @@ def main(request=None):
win.protocol('WM_DELETE_WINDOW', close)
def channel_changed(_=None):
refresh_branch_ui()
if branch_ui_enabled():
load_branch_list()
check_now()
choice.bind('<<ComboboxSelected>>', channel_changed)
refresh_branch_ui()
if branch_ui_enabled():
load_branch_list()
def poll():
nonlocal candidate, busy, automatic
@@ -567,6 +603,9 @@ def main(request=None):
if kind == 'progress':
status.set(value)
continue
if kind == 'branches':
apply_branch_choices(value)
continue
busy = False
if closing:
win.destroy()
@@ -583,12 +622,9 @@ def main(request=None):
win.destroy()
return
elif kind == 'checked':
branch_names, candidate = value
branch_names, candidate, branch_status = value
if branch_names:
values = [name for name in branch_names if isinstance(name, str) and name]
if branch.get().strip() and branch.get().strip() not in values:
values.insert(0, branch.get().strip())
branch_choice.configure(values=values)
apply_branch_choices(branch_names)
if candidate:
automatic = False
win.deiconify()
@@ -611,8 +647,10 @@ def main(request=None):
win.destroy()
return
else:
if channel.get() == 'Branch':
status.set('No successful CI build found for this branch yet, or you already have that revision.')
if channel.get() == 'Branch' and branch_status and branch_status[0]:
status.set(branch_status[0])
elif channel.get() == 'Branch':
status.set('No published branch release found, or you already have that revision.')
elif repair_files:
status.set('Repair needed, but this build is not available in the selected channel. Select its channel or a newer release.')
else: