Starlet: keep a matched alarm's timer cause until HW_ALARM is rewritten

A console run (StarOS M3C gate H1, 2026-10-16) acknowledged the timer
through HW_ARMIRQFLAG before rewriting HW_ALARM, tens of microseconds after
the match, and read the cause still set. MINI's IRQ handler rewrites
HW_ALARM first, then acknowledges. The model fired the cause once on the
crossing, and a write-one-to-clear ended it for good, so the probe hid the
difference.

Once the counter reaches HW_ALARM, the timer's cause now stays asserted:
an acknowledgement sets it again until HW_ALARM is written. Writing the
comparator re-arms it (an immediate match when equal to the counter, as
before); a counter write does not end the match. One run does not tell a
latch from a "counter >= alarm" comparison: the latch is the smaller model
that explains it. The state is saved (STATE_VERSION 197).

Tests: StarletTimer.MatchedAlarmStaysAssertedUntilRewritten (both write
paths, a counter write, MINI's order, the next match) and
MatchedAlarmSurvivesStateRoundTrip.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Yaya48andClaude Opus 5.5 committed 2026-10-16 12:51:16 +02:00
1 parent 6c44367b5c
commit dd2336f278
4 files changed
+119 -4

No files matched your search

+25 -3
View File
@@ -867,6 +867,7 @@ void StarletMemory::Reset()
ResetOHCIController(controller);
m_arm_cycles = 0;
m_timer_offset = 0;
m_alarm_matched = false;
m_boot0_mapped = true;
m_sram_split_mode = false;
// Retail Hollywood production revision. Early firmware branches on this
@@ -1071,6 +1072,7 @@ void StarletMemory::DoState(PointerWrap& p)
p.Do(m_seeprom_write_all);
p.Do(m_arm_cycles);
p.Do(m_timer_offset);
p.Do(m_alarm_matched);
p.Do(m_initialized);
p.Do(m_boot0_mapped);
p.Do(m_sram_split_mode);
@@ -1124,6 +1126,19 @@ void StarletMemory::UpdateInterruptLines(u32 address)
m_interrupt_core->SetFIQLine(ipc.IsStarletFIQAsserted());
}
void StarletMemory::KeepMatchedAlarmAsserted(u32 address)
{
// Once the counter has reached HW_ALARM, acknowledging the timer through
// HW_ARMIRQFLAG does not clear its cause until HW_ALARM is written again.
// Observed on a console: StarOS's M3C gate H1 (2026-10-16) acknowledged the
// timer before rewriting HW_ALARM, tens of microseconds after the match, and
// read the cause still set. MINI's handler rewrites HW_ALARM first. One run
// does not tell a latch from a "counter >= alarm" comparison; the latch is
// the smaller model that explains it.
if (address == HW_ARMIRQFLAG && m_alarm_matched)
m_system.GetWiiIPC().SetStarletInterrupt(INT_CAUSE_TIMER, true);
}
u32 StarletMemory::GetTimer() const
{
// Hollywood's timer runs at Starlet / 128 (1.8984375 MHz at 243 MHz).
@@ -1154,7 +1169,10 @@ void StarletMemory::AdvanceCycles(u64 cycles)
const u32 alarm = ReadRegister(HW_ALARM);
const u32 ticks_until_alarm = alarm - old_timer;
if (ticks_until_alarm != 0 && ticks_until_alarm <= elapsed_timer_ticks)
{
m_alarm_matched = true;
m_system.GetWiiIPC().SetStarletInterrupt(INT_CAUSE_TIMER, true);
}
if (m_nand_cycles_until_completion != 0)
{
@@ -4119,9 +4137,11 @@ void StarletMemory::Write8(u32 address, u8 value)
{
// HW_ALARM is a comparator, not an interrupt acknowledgement register.
// Original Starlet software acknowledges the timer separately through
// HW_ARMIRQFLAG. Programming the comparator to the current counter value
// creates an immediate match.
if (word == GetTimer())
// HW_ARMIRQFLAG. Programming the comparator re-arms it: a past match no
// longer holds the cause (KeepMatchedAlarmAsserted). Programming it to the
// current counter value creates an immediate match.
m_alarm_matched = word == GetTimer();
if (m_alarm_matched)
m_system.GetWiiIPC().SetStarletInterrupt(INT_CAUSE_TIMER, true);
}
else if (word_address == HW_SPARE0)
@@ -4167,6 +4187,7 @@ void StarletMemory::Write8(u32 address, u8 value)
word_address == HW_AHBPROT || word_address == HW_RESETS)
{
m_system.GetWiiIPC().WriteStarletRegister(word_address - HW_BASE, word);
KeepMatchedAlarmAsserted(word_address);
UpdateInterruptLines(word_address);
if (word_address == HW_BASE + 0x0c && (word & 0x09) != 0)
{
@@ -4250,6 +4271,7 @@ void StarletMemory::Write32(u32 address, u32 value)
{
WriteRegister(address, value);
m_system.GetWiiIPC().WriteStarletRegister(address - HW_BASE, value);
KeepMatchedAlarmAsserted(address);
UpdateInterruptLines(address);
// Phase 11 H1: an IPC reply (ARMCTRL Y1) audits the watched ranges.
if (!m_watch_ranges.empty() && address == HW_BASE + 0x0c && (value & 1) != 0)
@@ -119,6 +119,7 @@ private:
friend class StarletPairingTest;
void UpdateInterruptLines(u32 address);
void KeepMatchedAlarmAsserted(u32 address);
ARMCore* m_interrupt_core = nullptr;
friend class StarletDMATest;
@@ -357,6 +358,8 @@ private:
bool m_seeprom_write_all = false;
u64 m_arm_cycles = 0;
u32 m_timer_offset = 0;
// The counter has reached HW_ALARM since it was last written.
bool m_alarm_matched = false;
bool m_initialized = false;
// Phase 11 F1 write watch (REV_WATCH); empty when off.
std::vector<std::pair<u32, u32>> m_watch_ranges;
+1 -1
View File
@@ -97,7 +97,7 @@ struct CompressAndDumpStateArgs
static Common::WorkQueueThreadSP<CompressAndDumpStateArgs> s_compress_and_dump_thread;
// Don't forget to increase this after doing changes on the savestate system
constexpr u32 STATE_VERSION = 196; // Correct Hollywood DI/reset pending interrupt bit mapping.
constexpr u32 STATE_VERSION = 197; // Hollywood's alarm stays matched until HW_ALARM is rewritten.
// Increase this if the StateExtendedHeader definition changes
constexpr u32 EXTENDED_HEADER_VERSION = 1; // Last changed in PR 12217
@@ -649,6 +649,96 @@ TEST(StarletInterrupts, FIQMaskWritesResampleTheAttachedCoreLine)
EXPECT_FALSE(core.IsFIQLineAsserted());
}
TEST(StarletTimer, MatchedAlarmStaysAssertedUntilRewritten)
{
constexpr u32 hardware_base = 0x0d800000;
constexpr u32 timer = hardware_base + 0x10;
constexpr u32 alarm = hardware_base + 0x14;
constexpr u32 arm_irq_flag = hardware_base + 0x38;
constexpr u32 arm_irq_mask = hardware_base + 0x3c;
Core::DeclareAsCPUThread();
auto& system = Core::System::GetInstance();
auto& ipc = system.GetWiiIPC();
ipc.Reset();
StarletMemory memory(system);
memory.Reset();
ARMCore core(memory);
memory.AttachInterruptLines(&core);
const auto timer_cause = [&ipc] { return ipc.ReadStarletRegister(0x38) & INT_CAUSE_TIMER; };
memory.Write32(arm_irq_mask, INT_CAUSE_TIMER);
memory.Write32(alarm, 64);
memory.AdvanceCycles(64 * 128);
ASSERT_EQ(memory.Read32(timer), 64u);
ASSERT_EQ(timer_cause(), INT_CAUSE_TIMER);
core.SetIRQLine(ipc.IsStarletIRQAsserted());
ASSERT_TRUE(core.IsIRQLineAsserted());
// Acknowledged before HW_ALARM is rewritten, after the counter moved on: the
// cause and the line stay asserted (the console's StarOS M3C gate H1).
memory.AdvanceCycles(32 * 128);
memory.Write32(arm_irq_flag, INT_CAUSE_TIMER);
EXPECT_EQ(timer_cause(), INT_CAUSE_TIMER);
EXPECT_TRUE(core.IsIRQLineAsserted());
for (u32 i = 0; i < 4; ++i)
memory.Write8(arm_irq_flag + i, static_cast<u8>(INT_CAUSE_TIMER >> (24 - 8 * i)));
EXPECT_EQ(timer_cause(), INT_CAUSE_TIMER);
// A counter write does not end the match either.
memory.Write32(timer, 0);
memory.Write32(arm_irq_flag, INT_CAUSE_TIMER);
EXPECT_EQ(timer_cause(), INT_CAUSE_TIMER);
// MINI's order: rewrite HW_ALARM ahead, then acknowledge. The rewrite alone
// acknowledges nothing; the acknowledgement then clears the cause and the line.
memory.Write32(alarm, memory.Read32(timer) + 64);
EXPECT_EQ(timer_cause(), INT_CAUSE_TIMER);
memory.Write32(arm_irq_flag, INT_CAUSE_TIMER);
EXPECT_EQ(timer_cause(), 0u);
EXPECT_FALSE(core.IsIRQLineAsserted());
// The next match latches again.
memory.AdvanceCycles(64 * 128);
EXPECT_EQ(timer_cause(), INT_CAUSE_TIMER);
memory.Write32(arm_irq_flag, INT_CAUSE_TIMER);
EXPECT_EQ(timer_cause(), INT_CAUSE_TIMER);
}
TEST(StarletTimer, MatchedAlarmSurvivesStateRoundTrip)
{
constexpr u32 alarm = 0x0d800014;
constexpr u32 arm_irq_flag = 0x0d800038;
Core::DeclareAsCPUThread();
auto& system = Core::System::GetInstance();
auto& ipc = system.GetWiiIPC();
ipc.Reset();
StarletMemory memory(system);
memory.Reset();
memory.Write32(alarm, 8);
memory.AdvanceCycles(8 * 128);
ASSERT_EQ(ipc.ReadStarletRegister(0x38) & INT_CAUSE_TIMER, INT_CAUSE_TIMER);
std::vector<u8> state_buffer(1024 * 1024);
u8* state_pointer = state_buffer.data();
PointerWrap writer(&state_pointer, state_buffer.size(), PointerWrap::Mode::Write);
memory.DoState(writer);
ASSERT_TRUE(writer.IsWriteMode());
const size_t state_size = state_pointer - state_buffer.data();
// A reset forgets the match: the acknowledgement clears the cause.
memory.Reset();
memory.Write32(arm_irq_flag, INT_CAUSE_TIMER);
ASSERT_EQ(ipc.ReadStarletRegister(0x38) & INT_CAUSE_TIMER, 0u);
// The restored state holds it again.
state_pointer = state_buffer.data();
PointerWrap reader(&state_pointer, state_size, PointerWrap::Mode::Read);
memory.DoState(reader);
ASSERT_TRUE(reader.IsReadMode());
memory.Write32(arm_irq_flag, INT_CAUSE_TIMER);
EXPECT_EQ(ipc.ReadStarletRegister(0x38) & INT_CAUSE_TIMER, INT_CAUSE_TIMER);
}
TEST(StarletInterrupts, AcknowledgingOneOfTwoPendingSourcesKeepsIRQAsserted)
{
constexpr u32 hardware_base = 0x0d800000;