Decorations: keep them through IMVU's own room updates
CI / build (push) Successful in 33s

After the owner saves, IMVU's server sends everyone the saved room ({"command": "SetRoomState"} from
admin on the chat's control mount), which has no decorations: the proxy now adds them to it, and takes
community products out of any outgoing one. A change notice from the hub also no longer empties the
remembered decorations while the new ones are asked for (a broadcast arriving meanwhile went through
without them).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 05:00:11 -07:00
1 parent 0be2cd8f4e
commit 13ee023836
4 files changed
+159 -9

No files matched your search

+94 -9
View File
@@ -75,6 +75,14 @@ func (d *decorations) get(instance string) ([]DecorItem, bool) {
return e.items, true return e.items, true
} }
// last is the room's decorations as last known, however old (a broadcast can't wait for the hub).
func (d *decorations) last(instance string) ([]DecorItem, bool) {
d.mu.Lock()
defer d.mu.Unlock()
e, ok := d.rooms[instance]
return e.items, ok
}
func (d *decorations) set(instance string, items []DecorItem) { func (d *decorations) set(instance string, items []DecorItem) {
d.mu.Lock() d.mu.Lock()
defer d.mu.Unlock() defer d.mu.Unlock()
@@ -135,13 +143,19 @@ func (p *Products) decorFetch(ctx context.Context, instance string) []DecorItem
if items, ok := p.decor.get(instance); ok { if items, ok := p.decor.get(instance); ok {
return items return items
} }
return p.decorRefresh(ctx, instance)
}
// decorRefresh asks the hub for a room's decorations; what was known stays until the answer replaces it.
func (p *Products) decorRefresh(ctx context.Context, instance string) []DecorItem {
if p.Decorations == nil { if p.Decorations == nil {
return nil return nil
} }
items, err := p.Decorations(ctx, instance) items, err := p.Decorations(ctx, instance)
if err != nil { if err != nil {
p.logf("decor: %s: %v", instance, err) p.logf("decor: %s: %v", instance, err)
return nil old, _ := p.decor.last(instance)
return old
} }
p.decor.set(instance, items) p.decor.set(instance, items)
return items return items
@@ -457,36 +471,107 @@ func (p *Products) outgoingScene(m *chatMessage) ([]byte, bool) {
return m.withText(setRoomStateCommand + encodeScene(scene)), true return m.withText(setRoomStateCommand + encodeScene(scene)), true
} }
// incomingControlScene adds the room's decorations to IMVU's server's {"command": "SetRoomState", "state": scene}
// on a chat's control mount.
func (p *Products) incomingControlScene(message []byte) ([]byte, bool) {
if !bytes.Contains(message, []byte(`"SetRoomState"`)) {
return nil, false
}
var fields map[string]json.RawMessage
if json.Unmarshal(message, &fields) != nil {
return nil, false
}
var command, state string
if json.Unmarshal(fields["command"], &command) != nil || command != "SetRoomState" || json.Unmarshal(fields["state"], &state) != nil {
return nil, false
}
scene, ok := decodeScene(state)
if !ok {
return nil, false
}
encoded, changed := p.decorateScene(scene)
if !changed {
return nil, false
}
fields["state"], _ = json.Marshal(encoded)
out, err := json.Marshal(fields)
return out, err == nil
}
// outgoingControlScene takes community products out of a SetRoomState the client sends on a control mount (it
// isn't known to, but nothing naming them goes to IMVU).
func (p *Products) outgoingControlScene(message []byte) ([]byte, bool) {
if !bytes.Contains(message, []byte(`"SetRoomState"`)) {
return nil, false
}
var fields map[string]json.RawMessage
var state string
if json.Unmarshal(message, &fields) != nil || json.Unmarshal(fields["state"], &state) != nil {
return nil, false
}
scene, ok := decodeScene(state)
if !ok {
return nil, false
}
holder, rows := sceneContents(scene)
rest, _, stripped := p.splitCommunity(rows)
if stripped == 0 {
return nil, false
}
if rest == nil {
rest = []any{}
}
holder["room_contents"] = rest
fields["state"], _ = json.Marshal(encodeScene(scene))
out, err := json.Marshal(fields)
p.logf("decor: kept %d community products out of a room state sent to IMVU", stripped)
return out, err == nil
}
// incomingScene adds the room's decorations to its owner's broadcast. // incomingScene adds the room's decorations to its owner's broadcast.
func (p *Products) incomingScene(m *chatMessage) ([]byte, bool) { func (p *Products) incomingScene(m *chatMessage) ([]byte, bool) {
scene, ok := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand)) scene, ok := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand))
if !ok { if !ok {
return nil, false return nil, false
} }
encoded, changed := p.decorateScene(scene)
if !changed {
return nil, false
}
return m.withText(setRoomStateCommand + encoded), true
}
// decorateScene adds the room's decorations to a scene IMVU sent: the scene encoded again, and whether it changed.
func (p *Products) decorateScene(scene map[string]any) (string, bool) {
instance := sceneInstance(scene) instance := sceneInstance(scene)
if instance == "" || isHostedInstance(instance) { if instance == "" || isHostedInstance(instance) {
return nil, false return "", false
} }
holder, rows := sceneContents(scene) holder, rows := sceneContents(scene)
p.decor.setBase(instance, rows, holder["room_info"]) p.decor.setBase(instance, rows, holder["room_info"])
items, _ := p.decor.get(instance) items, fresh := p.decor.get(instance)
if !fresh {
items, _ = p.decor.last(instance) // stale or none: used now, asked for again for next time
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
p.decorRefresh(ctx, instance)
}()
}
extra := p.decorLocals(items) extra := p.decorLocals(items)
if len(extra) == 0 { if len(extra) == 0 {
return nil, false return "", false
} }
holder["room_contents"] = append(append([]any{}, rows...), extra...) holder["room_contents"] = append(append([]any{}, rows...), extra...)
return m.withText(setRoomStateCommand + encodeScene(scene)), true return encodeScene(scene), true
} }
// DecorChanged is told by the hub that a room's decorations changed: they're asked for again, and if the client is // DecorChanged is told by the hub that a room's decorations changed: they're asked for again, and if the client is
// in that room, it gets the room again with them (as its owner's broadcast). // in that room, it gets the room again with them (as its owner's broadcast).
func (p *Products) DecorChanged(instance string) { func (p *Products) DecorChanged(instance string) {
p.decor.mu.Lock()
delete(p.decor.rooms, instance)
p.decor.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
items := p.decorFetch(ctx, instance) items := p.decorRefresh(ctx, instance) // the old ones stay in use until then
if roomOwner(instance) == p.User() { if roomOwner(instance) == p.User() {
return // the owner's client has the room as it is return // the owner's client has the room as it is
} }
+52
View File
@@ -92,3 +92,55 @@ func TestDecorations(t *testing.T) {
t.Fatalf("delivered: %v", rows) t.Fatalf("delivered: %v", rows)
} }
} }
// A broadcast arriving while the proxy asks the hub for changed decorations still gets the ones known so far.
func TestDecorationsWhileRefreshing(t *testing.T) {
h, _ := newHarness(t)
hubDir := t.TempDir()
writePack(t, hubDir, "sofasofa22", 1)
h.p.dirs = append(h.p.dirs, hubDir)
h.p.HubDir = hubDir
h.p.refresh(true)
sofa := h.p.byProductID("sofasofa22").local
release := make(chan struct{})
asked := make(chan struct{}, 1)
h.p.Decorations = func(ctx context.Context, instance string) ([]DecorItem, error) {
asked <- struct{}{}
<-release
return []DecorItem{{11, "sofasofa22", "x"}, {12, "sofasofa22", "y"}}, nil
}
h.p.decor.set("7-1", []DecorItem{{11, "sofasofa22", "x"}})
go h.p.DecorChanged("7-1")
<-asked // the hub is being asked
scene := map[string]any{"room_state": map[string]any{
"room_info": map[string]any{"room_instance_id": "7-1"}, "room_contents": []any{[]any{10, 301, "2-1"}}}}
msg, _ := json.Marshal(map[string]any{"userId": 7, "chatId": 555, "message": setRoomStateCommand + encodeScene(scene)})
in := h.p.Incoming(nil, imq.Delivered{UserID: "7", Queue: "/chat/555", Mount: "messages", Message: msg})
close(release)
m, _ := parseChatMessage(in)
got, _ := decodeScene(strings.TrimPrefix(m.text, setRoomStateCommand))
if _, rows := sceneContents(got); len(rows) != 2 || !strings.Contains(fmt.Sprint(rows), fmt.Sprint(sofa)) {
t.Fatalf("delivered %v", rows)
}
}
// IMVU's server sends a saved room on the chat's control mount; decorations are added to it too.
func TestDecorationsInServerRoomState(t *testing.T) {
h, _ := newHarness(t)
hubDir := t.TempDir()
writePack(t, hubDir, "sofasofa22", 1)
h.p.dirs = append(h.p.dirs, hubDir)
h.p.HubDir = hubDir
h.p.refresh(true)
h.p.decor.set("7-1", []DecorItem{{11, "sofasofa22", "x"}})
scene := map[string]any{"room_state": map[string]any{
"room_info": map[string]any{"room_instance_id": "7-1"}, "room_contents": []any{[]any{10, 301, "2-1"}}}}
msg, _ := json.Marshal(map[string]any{"command": "SetRoomState", "state": encodeScene(scene)})
in := h.p.Incoming(nil, imq.Delivered{UserID: "admin", Queue: "/chat/555", Mount: "control", Message: msg})
var fields map[string]string
json.Unmarshal(in, &fields)
got, _ := decodeScene(fields["state"])
if _, rows := sceneContents(got); len(rows) != 2 || fields["command"] != "SetRoomState" {
t.Fatalf("delivered %s", in)
}
}
+6
View File
@@ -578,6 +578,12 @@ func (p *Products) Outgoing(queue, mount string, message []byte) ([]byte, bool)
} }
func (p *Products) outgoing(queue, mount string, message []byte) ([]byte, bool) { func (p *Products) outgoing(queue, mount string, message []byte) ([]byte, bool) {
if strings.HasPrefix(queue, "/chat/") && mount == "control" {
if out, changed := p.outgoingControlScene(message); changed {
return out, false
}
return message, false
}
if !strings.HasPrefix(queue, "/chat/") || mount != "messages" { if !strings.HasPrefix(queue, "/chat/") || mount != "messages" {
return message, false return message, false
} }
+7
View File
@@ -146,6 +146,13 @@ func userOf(userID string) int64 {
// Incoming implements proxy.IMQWatcher: a T5DE user's whole outfit gets their community products added. // Incoming implements proxy.IMQWatcher: a T5DE user's whole outfit gets their community products added.
func (p *Products) Incoming(c *proxy.IMQConn, m imq.Delivered) []byte { func (p *Products) Incoming(c *proxy.IMQConn, m imq.Delivered) []byte {
chat := chatOf(m.Queue) chat := chatOf(m.Queue)
if chat != "" && m.Mount == "control" {
// IMVU's server sends everyone a room its owner saved (as IMVU has it: without decorations).
if out, changed := p.incomingControlScene(m.Message); changed {
return out
}
return m.Message
}
if chat == "" || m.Mount != "messages" { if chat == "" || m.Mount != "messages" {
return m.Message return m.Message
} }