Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/ReleaseSharedWorkflow2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ on:

jobs:
build-linux:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, ubuntu-22.04]
runs-on: ${{ matrix.os }}

env:
VERNAME: ${{ inputs.version }}
Expand All @@ -63,14 +66,14 @@ jobs:
- name: Save artifact
uses: actions/upload-artifact@v4
with:
name: linbin
path: autoortho_lin_*.bin
name: linbin_${{ matrix.os }}
path: autoortho_linux_*

- name: Release
if: ${{ inputs.relname != '' }}
uses: softprops/action-gh-release@v1
with:
files: autoortho_lin_*.bin
files: autoortho_linux_*
tag_name: ${{ inputs.relname }}
prerelease: ${{ inputs.prerelease }}

Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/build_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ on:

jobs:
build-linux:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, ubuntu-22.04]
runs-on: ${{ matrix.os }}
permissions:
contents: write

Expand All @@ -56,7 +59,7 @@ jobs:
- name: Save artifact
uses: actions/upload-artifact@v4
with:
name: linbin
name: linbin_${{ matrix.os }}
path: autoortho_linux_*.tar.gz

- name: Release
Expand Down
13 changes: 7 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ ZIP?=zip
VERSION?=0.0.0
# Sanitize VERSION for use in filenames (replace any non-safe char with '-')
SAFE_VERSION:=$(shell echo "$(VERSION)" | sed -e 's/[^A-Za-z0-9._-]/-/g')
CODE_NAME:=$(shell grep UBUNTU_CODENAME /etc/os-release | cut -d= -f2)
.PHONY: mac_app clean
SHELL := /bin/bash
.ONESHELL:
Expand All @@ -15,10 +16,10 @@ autoortho/.version:
# =============================================================================
# Linux Build (PyInstaller)
# =============================================================================
lin_bin: autoortho_linux_$(SAFE_VERSION)
autoortho_linux_$(SAFE_VERSION): autoortho/*.py autoortho/.version
lin_bin: autoortho_linux_$(SAFE_VERSION)_$(CODE_NAME)
autoortho_linux_$(SAFE_VERSION)_$(CODE_NAME): autoortho/*.py autoortho/.version
# Build inside Docker and rename output folder (all as root to avoid permission issues)
docker run --rm -v `pwd`:/code ubuntu:latest /bin/bash -c "\
docker run --rm -v `pwd`:/code ubuntu:$(CODE_NAME) /bin/bash -c "\
cd /code && \
./buildreqs.sh && \
. .venv/bin/activate && \
Expand All @@ -27,10 +28,10 @@ autoortho_linux_$(SAFE_VERSION): autoortho/*.py autoortho/.version
mv dist/autoortho autoortho_linux_$(SAFE_VERSION) && \
chmod -R a+rw autoortho_linux_$(SAFE_VERSION)"

lin_tar: autoortho_linux_$(SAFE_VERSION).tar.gz
autoortho_linux_$(SAFE_VERSION).tar.gz: autoortho_linux_$(SAFE_VERSION)
lin_tar: autoortho_linux_$(SAFE_VERSION)_$(CODE_NAME).tar.gz
autoortho_linux_$(SAFE_VERSION)_$(CODE_NAME).tar.gz: autoortho_linux_$(SAFE_VERSION)_$(CODE_NAME)
# Package entire folder (includes ao_files/ with all bundled dependencies)
tar -czf $@ $<
tar -czf $@ autoortho_linux_$(SAFE_VERSION)

bin: autoortho/.version
# Ensure required Linux libraries and helper binaries are executable before packaging
Expand Down
2 changes: 1 addition & 1 deletion autoortho.spec
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ exe = EXE(
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True, # Set to False for GUI-only mode
console=False, # GUI-only mode - no console window
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
Expand Down
3 changes: 3 additions & 0 deletions autoortho/aoconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ class AOConfig(object):
[fuse]
# Enable or disable multi-threading when using FUSE
threading = {False if system_type == "darwin" else True}
# Timeout in seconds for tile build operations. If a tile takes longer than this,
# a placeholder will be shown instead of crashing X-Plane. Default: 60
build_timeout = 60

[flightdata]
# Local port for map and stats
Expand Down
22 changes: 17 additions & 5 deletions autoortho/autoortho.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
_is_frozen,
is_only_ao_placeholder,
clear_ao_placeholder,
safe_ismount,
)
from utils.constants import MAPTYPES, system_type

Expand All @@ -44,6 +45,7 @@
# Import PyQt6 modules

from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon
import config_ui_qt as config_ui
USE_QT = True

Expand Down Expand Up @@ -109,7 +111,7 @@ def setupmount(mountpoint, systemtype):
had_placeholder = False

# Preflight: ensure mount dir is a directory and not currently mounted
if os.path.ismount(mountpoint):
if safe_ismount(mountpoint):
log.warning(f"{mountpoint} is already mounted; attempting to unmount")
try:
if systemtype in ("winfsp-FUSE", "dokan-FUSE"):
Expand Down Expand Up @@ -140,10 +142,10 @@ def setupmount(mountpoint, systemtype):
# Wait briefly for unmount to complete
deadline = time.time() + 10
while time.time() < deadline:
if not os.path.ismount(mountpoint):
if not safe_ismount(mountpoint):
break
time.sleep(0.5)
if os.path.ismount(mountpoint):
if safe_ismount(mountpoint):
raise MountError(f"{mountpoint} is already mounted")
# For WinFsp, the directory must NOT exist; let winsetup handle removal of placeholders.
if systemtype != "winfsp-FUSE":
Expand Down Expand Up @@ -808,11 +810,11 @@ def unmount(self, mountpoint, force=False):
if not force:
deadline = time.time() + 10
while time.time() < deadline:
if not os.path.ismount(mountpoint):
if not safe_ismount(mountpoint):
break
time.sleep(0.5)

if os.path.ismount(mountpoint):
if safe_ismount(mountpoint):
try:
import subprocess
if system_type == 'darwin':
Expand Down Expand Up @@ -926,6 +928,16 @@ def main():
log.info("Running CFG UI")
if USE_QT:
app = QApplication(sys.argv)

# Set application icon - required for macOS dock icon visibility
if system_type == 'darwin':
# Use native macOS icon format
app.setWindowIcon(QIcon(":/imgs/ao-icon.icns"))
elif system_type == 'windows':
app.setWindowIcon(QIcon(":/imgs/ao-icon.ico"))
else:
app.setWindowIcon(QIcon(":/imgs/ao-icon.png"))

cfgui = AOMountUI(CFG)
cfgui.show()
app.exec()
Expand Down
146 changes: 137 additions & 9 deletions autoortho/autoortho_fuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,116 @@

import getortho

def _rgb_to_rgb565(r: int, g: int, b: int) -> int:
"""Convert RGB888 to RGB565 format used by BC1/DXT1 compression."""
# RGB565: 5 bits red (high), 6 bits green (mid), 5 bits blue (low)
r5 = (r >> 3) & 0x1F
g6 = (g >> 2) & 0x3F
b5 = (b >> 3) & 0x1F
return (r5 << 11) | (g6 << 5) | b5

def _get_fallback_bc1_block() -> bytes:
"""
Generate an 8-byte BC1/DXT1 block using the configured missing_color.

BC1 block format:
- 2 bytes: color0 (RGB565, little-endian)
- 2 bytes: color1 (RGB565, little-endian)
- 4 bytes: 4x4 pixel indices (2 bits each)

For a solid color, we set color0 = color1 and all indices to 0.
"""
try:
# Get missing_color from config [R, G, B]
missing = CFG.autoortho.missing_color
if isinstance(missing, (list, tuple)) and len(missing) >= 3:
r, g, b = int(missing[0]), int(missing[1]), int(missing[2])
else:
# Fallback to default gray if config is malformed
r, g, b = 66, 77, 55
except Exception:
# Fallback to default if config not available
r, g, b = 66, 77, 55

# Convert to RGB565
rgb565 = _rgb_to_rgb565(r, g, b)

# Pack as little-endian 2-byte value (appears twice for color0 and color1)
color_bytes = rgb565.to_bytes(2, 'little')

# BC1 block: color0, color1, 4 bytes of indices (all 0 for solid color)
return color_bytes + color_bytes + b'\x00\x00\x00\x00'

# Cache the BC1 block to avoid recomputing on every call
_cached_bc1_block = None

# DDS header size is always 128 bytes
_DDS_HEADER_SIZE = 128

def _generate_fallback_dds_bytes(offset: int, length: int) -> bytes:
"""
Generate fallback DDS bytes for when tile generation fails.

This returns valid DDS-compatible data that X-Plane can safely render,
preventing EXCEPTION_IN_PAGE_ERROR crashes on Windows.

The fallback uses the configured missing_color from [autoortho] section,
converted to BC1/DXT1 compressed format. This ensures the placeholder
texture visually matches the missing tile color the user has configured.

The function properly handles the offset parameter to return the correct
portion of the fallback DDS data:
- Bytes 0-127: DDS header region (returns zeros for valid structure)
- Bytes 128+: Mipmap data region (returns BC1 blocks in missing_color)
"""
global _cached_bc1_block

# Generate and cache the BC1 block on first use
if _cached_bc1_block is None:
_cached_bc1_block = _get_fallback_bc1_block()

block = _cached_bc1_block
block_size = 8 # BC1 blocks are 8 bytes each

result = bytearray()
current_offset = offset
remaining = length

# Handle header region (bytes 0-127)
# Return zeros for header bytes - this is safe because X-Plane will see
# an invalid/empty DDS header and handle it gracefully
if current_offset < _DDS_HEADER_SIZE:
header_bytes_needed = min(remaining, _DDS_HEADER_SIZE - current_offset)
result.extend(b'\x00' * header_bytes_needed)
current_offset += header_bytes_needed
remaining -= header_bytes_needed

# Handle mipmap data region (bytes 128+)
if remaining > 0:
# Calculate position within mipmap data (offset from byte 128)
mipmap_offset = current_offset - _DDS_HEADER_SIZE

# Calculate where we are within the 8-byte BC1 block pattern
block_phase = mipmap_offset % block_size

# If we're not aligned to block boundary, add partial block first
if block_phase > 0:
partial_len = min(remaining, block_size - block_phase)
result.extend(block[block_phase:block_phase + partial_len])
remaining -= partial_len

# Add complete blocks
if remaining >= block_size:
full_blocks = remaining // block_size
result.extend(block * full_blocks)
remaining -= full_blocks * block_size

# Add any remaining partial block
if remaining > 0:
result.extend(block[:remaining])

return bytes(result)

def deg2num(lat_deg, lon_deg, zoom):
lat_rad = math.radians(lat_deg)
n = 2.0 ** zoom
Expand Down Expand Up @@ -500,23 +610,41 @@ def read(self, path, length, offset, fh):
zoom = int(zoom)
key = self._tile_key(row, col, maptype, zoom)
lock = self._tile_locks[key]
if not lock.acquire(timeout=CFG.fuse.build_timeout if hasattr(CFG.fuse, 'build_timeout') else 60):
self._failfast(f"Tile build lock timeout for {key}")

# Get configurable timeout, default 60 seconds
build_timeout = getattr(CFG.fuse, 'build_timeout', 60)
if isinstance(build_timeout, str):
try:
build_timeout = int(build_timeout)
except ValueError:
build_timeout = 60

if not lock.acquire(timeout=build_timeout):
# CRITICAL FIX: Instead of raising EIO (which causes CTD on Windows
# due to EXCEPTION_IN_PAGE_ERROR), return fallback placeholder data.
# X-Plane will show a gray/missing texture, but won't crash.
log.error(f"Tile build lock timeout for {key} after {build_timeout}s - returning fallback data")
return _generate_fallback_dds_bytes(offset, length)

try:
t = self.tc._get_tile(row, col, maptype, zoom)
data = t.read_dds_bytes(offset, length)
if data is None:
log.error(f"Tile read returned None for {key}")
raise FuseOSError(errno.EIO)
# CRITICAL FIX: Return fallback data instead of EIO
log.error(f"Tile read returned None for {key} - returning fallback data")
return _generate_fallback_dds_bytes(offset, length)
return data
except FuseOSError:
# Propagate specific FS error without killing the mount
raise
# CRITICAL FIX: Catch EIO and return fallback instead
# This prevents Windows EXCEPTION_IN_PAGE_ERROR CTD
log.error(f"FUSE error for tile {key} - returning fallback data to prevent CTD")
return _generate_fallback_dds_bytes(offset, length)
except Exception as e:
# Log and map to EIO, but do not exit the whole FUSE session
log.error(f"Tile read/build failed for {key}")
# CRITICAL FIX: Return fallback data instead of EIO
# This prevents Windows EXCEPTION_IN_PAGE_ERROR CTD
log.error(f"Tile read/build failed for {key} - returning fallback data to prevent CTD")
log.exception("cause:", exc_info=e)
raise FuseOSError(errno.EIO)
return _generate_fallback_dds_bytes(offset, length)
finally:
lock.release()

Expand Down
Loading