diff --git a/AIDisclosure.md b/AIDisclosure.md new file mode 100644 index 00000000..f0f2fa42 --- /dev/null +++ b/AIDisclosure.md @@ -0,0 +1,118 @@ +# AI Disclosure + +This is a disclosure of the use of AI in this project. + +## AI Use + +Anyone who demands the dislosure of how AI was used in an engineering project, +has no idea what AI is, or how it works. + +AI was used in this project as a tool. Several contributors used AI in their +commits, and so you will see like 7 commits by Claude or Codex or Cursor. + +However those commits were reviewed by human beings, and it was declared that +the exact same fix would have been done by a human, so they were accepted. + +AI was not used to make decisions, or to create the project. + +If you would like to read more, well then continue reading: + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque ante leo, luctus in semper a, maximus ut est. Vivamus nec magna vitae quam luctus suscipit nec eu orci. Vestibulum ut felis a dolor cursus vulputate. Phasellus pharetra elementum sollicitudin. Aenean elementum imperdiet ultrices. In risus mauris, scelerisque sed viverra in, iaculis non eros. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin quam justo, maximus sit amet fringilla in, tristique eget sapien. + +Pellentesque rhoncus, erat nec elementum ultricies, augue purus suscipit augue, in condimentum nisl enim vel velit. Etiam at semper turpis. Vestibulum ipsum magna, ultrices non sem ut, hendrerit bibendum mi. Curabitur in varius sapien. Morbi posuere bibendum ex, at ultrices orci. Fusce feugiat ultrices varius. Suspendisse sed ante ligula. Sed dignissim lorem est, nec tristique arcu commodo sed. Cras consectetur sapien dolor, vitae finibus enim lacinia id. + +Donec quis magna est. Maecenas dui arcu, venenatis sit amet libero nec, lacinia eleifend leo. Quisque lobortis vulputate lacus a elementum. Proin nec metus lectus. Donec eu auctor sem, at finibus ipsum. Curabitur eget dignissim justo. Donec lobortis leo eu arcu tristique, in volutpat augue eleifend. Morbi lacinia a risus in suscipit. Maecenas suscipit est eu interdum dictum. Cras in nulla imperdiet, dapibus mauris posuere, facilisis velit. Nunc dapibus, leo quis interdum tempor, elit mi mattis dolor, sagittis dictum mi urna sed lectus. Maecenas elementum, mauris id molestie dapibus, diam arcu egestas erat, at tempor justo orci vitae nibh. Etiam sagittis facilisis erat a vulputate. Praesent condimentum ac odio quis sollicitudin. + +Fusce vitae orci vestibulum, sagittis dolor non, cursus urna. Morbi eleifend pretium pellentesque. Pellentesque ornare elementum sem in imperdiet. Maecenas dapibus, erat et lobortis porttitor, velit magna auctor odio, quis interdum elit est eu justo. In posuere euismod odio, in porttitor magna iaculis eget. In id quam pulvinar, ultrices dolor in, pellentesque dolor. Nunc varius ante at felis dictum, id porttitor sem efficitur. Integer pretium dignissim commodo. Suspendisse in est a arcu blandit faucibus. Donec quis lacus mollis, tincidunt nunc quis, suscipit nunc. Nunc non arcu dignissim, dignissim sem in, finibus neque. Aliquam non porta eros. Donec et pretium augue, non cursus eros. + +Nunc at dignissim nisi. Nam nec metus augue. Proin nulla sapien, tristique a purus vel, vulputate commodo mi. Sed id erat leo. Quisque ullamcorper a nisl id molestie. Aliquam erat volutpat. Donec eget hendrerit mauris. Fusce tincidunt nisl a lorem tincidunt dapibus. Nam volutpat rhoncus tortor. + +Nunc et sapien enim. Proin at nunc a nulla maximus consectetur nec eget tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Mauris orci odio, sodales et elementum laoreet, porta at lacus. Maecenas vestibulum lectus risus, pulvinar scelerisque dolor posuere viverra. Proin gravida tellus vitae accumsan dignissim. Nunc non sapien aliquet ex cursus ultricies ac quis diam. Sed luctus feugiat risus eu tincidunt. Duis auctor lacinia fringilla. Donec pretium cursus magna a feugiat. Duis tristique, leo vulputate semper iaculis, est ipsum dapibus lacus, et molestie nulla enim nec ex. Nunc non feugiat neque. + +Fusce euismod egestas elit ut pretium. Nulla eros quam, auctor sit amet faucibus eu, scelerisque eu neque. Sed nisi felis, lobortis in sapien a, tempor efficitur nunc. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Pellentesque ut justo est. Sed maximus, tellus in bibendum posuere, quam augue finibus eros, sed gravida arcu diam quis lectus. Donec eu placerat ligula. Ut quis imperdiet lorem. Maecenas a mi ac augue semper sodales. Curabitur in justo velit. Praesent et felis quis enim porttitor sagittis. + +Nulla sed sagittis felis, sit amet placerat tortor. Ut metus est, sollicitudin ac turpis quis, aliquet congue lorem. Fusce auctor erat non convallis aliquet. Nullam sodales rutrum tellus ac malesuada. Quisque sem diam, iaculis in ultricies sit amet, fermentum quis sem. Integer condimentum placerat purus non lacinia. Integer hendrerit ultricies tellus, at dignissim nibh. Suspendisse accumsan eget tortor nec cursus. Proin accumsan rhoncus leo, eget pretium est tristique ac. + +Sed feugiat sed diam a porta. Nullam varius lacus at fermentum fringilla. Morbi pharetra scelerisque pharetra. Nulla placerat vitae ligula non efficitur. Suspendisse quam dui, rutrum eget nulla eu, semper eleifend ante. Aenean ut condimentum arcu. Suspendisse auctor metus non sem ornare, vel tincidunt odio vehicula. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. + +Donec vel metus ut libero sollicitudin posuere a nec nulla. Vivamus a scelerisque nisi. Aliquam eu sollicitudin tortor. Aliquam purus eros, blandit et quam et, pretium porttitor sem. Nunc iaculis arcu enim, et maximus sem malesuada in. Morbi nec nunc volutpat, semper diam sit amet, gravida elit. Vestibulum eu turpis vel lacus imperdiet congue. Donec rhoncus auctor sem. + +Suspendisse eu lorem non dolor pretium finibus euismod quis dolor. Cras finibus egestas velit, commodo rutrum est placerat sit amet. Pellentesque vitae semper diam, sit amet auctor metus. Sed porttitor porttitor nunc, vel imperdiet neque volutpat quis. Sed hendrerit sapien et lacus imperdiet, nec hendrerit lorem sodales. Integer lobortis rutrum odio at ullamcorper. Aliquam tincidunt magna a tellus suscipit, at porta turpis dapibus. Ut ultricies auctor felis eu feugiat. Sed tempus sem et dictum fringilla. Nunc non pellentesque tortor. Suspendisse pulvinar, arcu ut imperdiet gravida, eros ex mattis mauris, vel ultricies est erat et dui. Praesent porttitor tortor et erat interdum efficitur. Phasellus et luctus lectus, et egestas ante. Praesent ex ipsum, rutrum id efficitur et, vulputate non tortor. Aenean maximus nunc ac purus sodales, et venenatis lacus laoreet. + +Cras egestas ultrices dui, at tempor leo varius vitae. Donec porta, nisl nec ornare maximus, est arcu auctor mauris, varius elementum nisl arcu venenatis neque. Aenean metus quam, vestibulum eget justo non, hendrerit dapibus nunc. Vivamus diam ante, mattis sed nulla at, iaculis elementum magna. Sed massa diam, efficitur vel nunc sed, malesuada interdum tortor. Aliquam non neque aliquet ex imperdiet finibus eget ac neque. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; + +Pellentesque fringilla tortor metus, luctus commodo leo gravida et. Fusce nec turpis at lorem rutrum porta vel in justo. Curabitur mattis suscipit felis, id dapibus arcu ornare ut. Proin sapien felis, pulvinar ut tristique vitae, aliquet ac dui. Vestibulum a erat tellus. Vestibulum sagittis dolor eget augue egestas fringilla. Fusce et purus a nunc auctor dapibus vel sed dui. Fusce interdum, libero vel pellentesque rutrum, urna massa iaculis tellus, et aliquam lectus diam vel sem. Donec a nunc et dui semper gravida. Donec posuere, eros eu consequat efficitur, justo metus ullamcorper lectus, et molestie massa ipsum eu sapien. Mauris eu suscipit neque. Morbi convallis sit amet leo a scelerisque. Cras ultrices libero ac mattis accumsan. Nam gravida ligula id erat semper ornare. Duis consequat ut ipsum eu volutpat. Quisque egestas sollicitudin ullamcorper. + +Cras pellentesque quam non neque porta fringilla. Integer elementum, augue mattis blandit consequat, enim ipsum finibus ex, quis finibus neque eros eu ex. Fusce at urna justo. Donec erat eros, maximus id mauris vel, rutrum rutrum sapien. Morbi sed rutrum ex. Suspendisse lacus velit, varius ut elementum vitae, finibus non enim. Suspendisse vehicula euismod ipsum, id consequat nulla sodales vel. Morbi eu sem id leo congue dapibus a nec velit. Sed nec neque quam. Etiam rhoncus id nulla id volutpat. Nulla facilisi. Donec non maximus enim. Aenean consequat, sapien sit amet malesuada rutrum, erat sem euismod sapien, et feugiat lectus mauris id velit. + +Fusce sodales porttitor gravida. Proin placerat ante nec nibh tempor aliquam. Sed ut diam eu sem fringilla malesuada. Maecenas aliquam risus vel quam dictum, at iaculis nibh pretium. Mauris convallis quam vitae dolor varius suscipit. Etiam nec fermentum dui. Aliquam in magna tincidunt, consectetur quam eget, aliquam purus. Ut dictum aliquet finibus. Pellentesque vel lacinia felis. Nulla malesuada vestibulum varius. Sed quam diam, efficitur id felis in, volutpat bibendum erat. Praesent luctus vulputate urna at interdum. Aenean ac aliquam eros. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Morbi mollis, nisl vel consequat vehicula, massa tellus porta arcu, vel dictum sapien mauris sit amet nunc. Integer sed maximus neque, ac iaculis urna. + +Integer non erat a leo euismod convallis quis eget magna. Morbi gravida ac urna sed ornare. Nunc vehicula mauris accumsan, ornare sem in, egestas mauris. Vestibulum vel vulputate felis. Nulla eu scelerisque diam. Suspendisse ac odio tempor nunc pellentesque hendrerit at a magna. Vivamus ultrices nunc ut orci fermentum pharetra. Nullam laoreet hendrerit ligula ut gravida. Proin scelerisque magna sit amet arcu malesuada, pharetra ultrices est molestie. Nullam pulvinar placerat dui, vitae hendrerit tortor luctus sed. Pellentesque elementum tellus eget arcu pulvinar varius. + +Ut placerat, magna vitae tincidunt ultricies, est orci aliquet urna, at luctus augue erat vel ipsum. Fusce odio sem, venenatis vel consequat nec, bibendum sed dolor. Cras a sodales eros. Nullam eget dui congue, vehicula purus ut, condimentum dui. Maecenas libero ipsum, condimentum tincidunt nisi in, sodales lacinia ex. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. + +Duis gravida velit ac euismod suscipit. Proin sed ligula erat. Nullam eu ornare massa, non fringilla felis. Curabitur eu erat ex. Quisque sit amet dolor id arcu mattis scelerisque at at eros. In arcu nulla, fermentum non maximus eget, rhoncus in lacus. Nulla sit amet augue eu tortor vulputate congue. Vivamus laoreet condimentum tempus. Maecenas tempor, diam sed laoreet venenatis, mauris arcu lacinia enim, quis facilisis nunc turpis a ligula. Pellentesque quis placerat nisi, sit amet ullamcorper diam. Suspendisse a elementum elit, vel tristique dolor. Nam pretium ante tortor, vel tristique ipsum ultricies ut. Quisque non lectus imperdiet, placerat erat ac, pharetra tellus. In condimentum at magna a posuere. Aliquam et fringilla ipsum. Sed facilisis, nulla a finibus gravida, elit elit vulputate velit, dictum ornare est sapien a nisl. + +Mauris eleifend vulputate felis sed mattis. Praesent id velit vitae ex porta pretium. Cras mollis malesuada justo, ut ornare quam placerat ac. Donec lobortis arcu tellus, luctus tempor mi malesuada quis. Maecenas condimentum libero vitae finibus malesuada. Vestibulum sollicitudin fringilla diam eget egestas. Sed vulputate urna nec ipsum maximus hendrerit. Maecenas blandit ex ut massa sodales, vitae tincidunt lorem ullamcorper. Phasellus vitae nisl ornare, cursus sem a, pulvinar arcu. Vestibulum faucibus risus nec tincidunt pellentesque. Pellentesque vel porttitor ex. Vivamus sollicitudin gravida lacus in suscipit. Aliquam urna neque, sodales quis quam ac, suscipit condimentum ante. + +Morbi id arcu sit amet sapien ornare gravida eget quis sapien. In hac habitasse platea dictumst. In quis interdum ligula. Donec sed mi vulputate, scelerisque turpis vitae, interdum odio. Proin tristique condimentum arcu, et malesuada tellus convallis in. Fusce egestas maximus magna, sit amet convallis velit porttitor ut. Curabitur venenatis lacus ut blandit convallis. Phasellus scelerisque congue turpis eget vehicula. Nam venenatis mi sit amet rhoncus pretium. Nulla sed odio purus. Phasellus cursus id sapien ut feugiat. Duis et ipsum vel dui tempus porta. Curabitur non tortor consectetur, sollicitudin tellus mollis, sollicitudin lorem. + +Ut quis ornare justo. Nunc aliquam, leo sit amet placerat placerat, dui nulla luctus dui, ac iaculis nisl orci id metus. Vestibulum nunc nunc, porta nec dictum id, feugiat et ante. Quisque lobortis, lacus tristique vestibulum rhoncus, massa nulla dignissim massa, at scelerisque massa nunc at velit. Donec eu ipsum nec dui luctus pulvinar et et turpis. Quisque eu neque erat. Donec varius egestas nunc, ut pretium libero tempor ac. Vestibulum pellentesque mi erat, et semper dolor semper vitae. Morbi enim dui, laoreet non venenatis sit amet, dignissim a orci. Sed id odio turpis. Phasellus non rutrum magna. Maecenas placerat arcu ultricies ultrices congue. Nulla quis neque ligula. Etiam in diam commodo, pharetra mauris ac, pretium nisi. Praesent sed nibh nec odio condimentum commodo vel vel lacus. + +Pellentesque id libero vitae ex egestas pharetra placerat nec augue. Ut eget lobortis lorem, at vehicula sapien. Aliquam eu tincidunt ligula. Aenean et vestibulum dui, quis porttitor dui. Nullam quis dolor libero. Sed accumsan eros vitae nisi ornare congue. Aliquam nisi sapien, sollicitudin quis odio vel, pharetra maximus urna. + +Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Morbi accumsan felis id urna malesuada, non vulputate velit maximus. Proin urna velit, viverra a metus vel, sollicitudin faucibus nulla. Vivamus ipsum lectus, pharetra sit amet varius vel, ullamcorper nec neque. Suspendisse potenti. Vivamus sit amet justo ac augue placerat hendrerit in eu felis. Integer luctus ex quam, in pellentesque eros interdum sed. Fusce finibus quis neque vitae efficitur. Pellentesque vulputate consectetur egestas. Integer a neque fermentum, tincidunt ligula id, gravida urna. Pellentesque ultrices, leo et suscipit accumsan, lorem nunc porta dui, non congue ligula leo ut urna. Duis vehicula risus in mi eleifend luctus. Duis convallis, mi faucibus pellentesque cursus, libero mauris varius sem, sit amet fermentum massa metus nec tellus. In tortor ligula, faucibus eu nibh id, lobortis viverra erat. Morbi non nisi suscipit, mollis enim at, convallis velit. + +Pellentesque consequat imperdiet felis quis scelerisque. Duis aliquam mollis nibh quis tincidunt. Vivamus elit odio, blandit quis volutpat at, blandit nec tortor. Cras maximus ex at odio maximus, dapibus condimentum risus malesuada. Pellentesque viverra orci at ante commodo, quis posuere sapien efficitur. Nunc tristique imperdiet diam elementum lobortis. Fusce velit dui, ultrices id ante pharetra, fermentum egestas augue. Curabitur ante augue, vestibulum non magna quis, feugiat pulvinar diam. Suspendisse sagittis dui a tellus scelerisque, ut tincidunt neque accumsan. Cras pharetra metus vel eros tincidunt, vel tincidunt lacus egestas. Donec eget pellentesque sapien. Cras condimentum in justo pulvinar feugiat. Quisque malesuada ac odio eget rhoncus. Fusce posuere justo sed finibus ornare. + +Duis a auctor tortor. Pellentesque lobortis auctor risus, ultrices varius mi cursus quis. Sed dui nulla, mattis sit amet justo mattis, condimentum commodo justo. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Pellentesque hendrerit diam vel lacinia volutpat. Sed et luctus nunc, vitae congue arcu. Aenean placerat tincidunt ipsum. Ut molestie orci eu dapibus viverra. Cras sodales ullamcorper augue, at aliquam ex. Sed et justo augue. + +Nullam feugiat risus et turpis faucibus, vel tincidunt nulla consequat. Aliquam libero erat, pellentesque sit amet tellus in, tempor ornare nisl. Donec viverra eget magna non pharetra. Cras sollicitudin, justo ut porttitor venenatis, risus nulla auctor est, at commodo sem urna in mi. Vestibulum mattis sapien vel nibh pulvinar, vel dignissim lacus cursus. Maecenas vel dictum tortor, ac ultricies justo. Praesent quis venenatis nisl. Morbi a diam fringilla, auctor lectus sed, varius est. Praesent faucibus auctor dolor, a commodo nisi mattis id. Fusce porta molestie ultrices. Sed pulvinar, leo ac consectetur hendrerit, erat velit gravida enim, eu blandit ligula justo sit amet neque. + +Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean libero risus, porttitor in congue vel, consequat sed felis. Vivamus eget blandit ante. Vivamus vitae mattis massa. In ut dolor sit amet tellus sollicitudin mattis. Mauris iaculis nisl neque, in gravida lectus ultricies nec. Fusce vehicula vehicula lacinia. Fusce viverra sed nisl id rhoncus. Donec sed porta mi. Nam tempus purus non massa tincidunt iaculis. Morbi viverra massa ut gravida vestibulum. + +Proin dapibus mi a libero sagittis, id vehicula nulla iaculis. Proin a enim in tortor tincidunt egestas. Integer finibus neque eu nibh pretium, et pellentesque urna finibus. Integer a sollicitudin mauris, at convallis erat. Nullam sit amet lectus sed turpis commodo efficitur. Nulla nec turpis dapibus, suscipit diam quis, vulputate urna. Aenean sed posuere justo. + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Sed luctus purus nibh, id imperdiet risus pulvinar in. Quisque auctor sem lacinia turpis mollis, nec pretium ipsum suscipit. Aliquam sed metus sagittis, mollis nisi eget, hendrerit libero. Nulla sodales erat semper nisl condimentum, ultricies rhoncus lacus commodo. Ut suscipit libero augue, non vulputate dui tristique vel. Praesent convallis efficitur est, sed tincidunt mauris aliquet in. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. + +Etiam a vehicula purus. Curabitur lacus erat, ultrices et dictum id, posuere ut risus. Etiam at auctor leo. Pellentesque eleifend est a metus lacinia rutrum. Nullam justo ligula, tempor non facilisis vel, volutpat sed nulla. Integer non dolor consequat, dapibus lectus eu, luctus turpis. Aenean enim erat, sagittis vitae ornare bibendum, faucibus sit amet magna. Phasellus suscipit ultrices faucibus. Sed at risus molestie, viverra ipsum vel, bibendum lectus. Pellentesque molestie vitae risus non viverra. Phasellus eleifend massa id odio sagittis mattis. Nullam velit mauris, viverra quis fermentum sit amet, vestibulum ut ex. Suspendisse imperdiet, sapien sed sagittis pharetra, nisi nibh vulputate metus, quis mattis dolor nisi ut lorem. Praesent vestibulum nibh vulputate lacus pulvinar tempor. Vestibulum vulputate diam ligula, vitae efficitur enim dapibus non. Etiam at ornare enim. + +Curabitur aliquet velit enim, euismod faucibus urna euismod sit amet. Vivamus viverra vulputate nulla, ut gravida neque rutrum in. Suspendisse potenti. Nulla vitae neque felis. Etiam eu erat ac nulla ornare volutpat. Quisque ut diam dui. Sed ut massa quis dolor volutpat eleifend. Duis posuere dolor sit amet varius auctor. Donec mollis malesuada erat, eu luctus libero viverra feugiat. Curabitur fermentum velit eu purus fringilla, consequat tincidunt diam rutrum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc volutpat vel est in vehicula. Morbi euismod, tortor ut posuere ullamcorper, velit justo ultricies lorem, vitae tincidunt erat ante vitae sem. Praesent semper, quam eget condimentum finibus, metus leo imperdiet augue, nec fringilla sem nunc id sapien. + +Aliquam vestibulum ante porta sem finibus, ut rhoncus elit sodales. Phasellus a lacus congue, sagittis nulla eget, cursus libero. Duis laoreet fringilla faucibus. Aenean gravida lorem sed fringilla facilisis. Pellentesque sodales urna lorem, non rutrum tortor vulputate eget. Duis a enim semper, iaculis sem ac, facilisis urna. Donec iaculis nulla sit amet dignissim volutpat. Mauris cursus dui id feugiat suscipit. Fusce tempor placerat nulla vitae vestibulum. Vivamus imperdiet blandit nulla, in aliquet justo viverra in. Cras malesuada molestie ligula sit amet volutpat. Praesent ornare orci sit amet rutrum eleifend. Ut placerat metus felis, id malesuada justo mollis eget. Etiam mi turpis, pulvinar in pulvinar in, tincidunt in neque. + +In venenatis euismod neque, eu convallis diam semper ac. Mauris auctor mi non massa vestibulum viverra. Aenean non turpis sapien. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Praesent gravida feugiat interdum. Vivamus sit amet consequat ligula. Praesent dictum nunc ac sapien ullamcorper consectetur. Ut malesuada blandit neque. + +Duis tincidunt mauris sit amet odio dictum accumsan. Suspendisse efficitur nibh magna, ac dapibus augue vulputate nec. Etiam lectus neque, sollicitudin quis sapien vitae, aliquet fermentum mi. Aenean interdum interdum rhoncus. Donec eu libero urna. Donec semper lacus eu nunc scelerisque, vitae viverra quam consectetur. Integer sagittis, nulla congue venenatis auctor, purus justo mollis metus, at rutrum magna arcu nec est. Maecenas felis lorem, consequat non cursus vitae, lobortis vitae nisi. Cras eget magna justo. Nullam sagittis tellus id luctus ultricies. Etiam a arcu efficitur, consectetur libero non, imperdiet turpis. Donec ac velit et nisl semper semper. Duis iaculis interdum nunc sed tempor. + +Sed diam odio, sagittis non dignissim nec, accumsan ac diam. Fusce sit amet dui sit amet justo ultrices viverra. Sed vel massa suscipit nibh porttitor laoreet in id nunc. Nam quis libero vitae nunc blandit sollicitudin et a lorem. Duis urna arcu, accumsan sed dignissim sit amet, vulputate at ex. Mauris porttitor libero mauris, vel fringilla diam euismod quis. Sed varius placerat tellus vel efficitur. Phasellus pulvinar gravida magna. Nulla dignissim consectetur finibus. + +Nunc quis aliquet nisi. Cras luctus bibendum eros ac dignissim. Aenean suscipit felis vitae elementum eleifend. Proin commodo nunc non diam dignissim, in tincidunt nulla ultrices. Vivamus faucibus quam scelerisque interdum finibus. Sed porttitor vehicula urna, in laoreet arcu condimentum non. Praesent ac lacus diam. Vivamus aliquam euismod risus, luctus dictum lectus sodales et. Proin quis velit ac massa tristique scelerisque. + +Sed non dolor efficitur, tincidunt mi eget, sagittis tortor. Quisque at varius felis, at finibus sem. Vestibulum vel lectus tincidunt, pharetra diam sit amet, interdum nulla. Sed ut elit tortor. Nam tincidunt tempus aliquam. Vivamus rhoncus faucibus sapien eget facilisis. Aliquam erat volutpat. Phasellus placerat aliquam lacus, eget ultrices orci pretium hendrerit. Fusce vitae dolor sit amet ante condimentum placerat. Nunc varius risus id tellus mollis, euismod luctus sapien viverra. Donec sodales est vel massa suscipit, eu sollicitudin ante convallis. Curabitur eu condimentum velit. Sed pharetra euismod tincidunt. + +Nam at libero eros. Quisque bibendum, ligula quis sagittis ullamcorper, eros leo consectetur ex, quis elementum dolor justo vitae mi. Maecenas et elementum erat, et auctor enim. Nunc at nibh fermentum, ullamcorper mi elementum, facilisis erat. Vestibulum vestibulum leo ut pellentesque placerat. Suspendisse imperdiet nisl vitae justo sodales pellentesque. Interdum et malesuada fames ac ante ipsum primis in faucibus. In faucibus pretium nunc, sed interdum lectus vestibulum quis. Vestibulum luctus viverra ex at efficitur. Etiam ac est lorem. Maecenas mollis, orci at rhoncus congue, nulla leo rutrum dui, et pellentesque orci ligula eget ipsum. Suspendisse fermentum nisi turpis, ut sollicitudin purus imperdiet non. Maecenas vitae quam ornare, porta sem quis, rhoncus neque. Donec mattis purus a erat tristique, ac mollis est convallis. Duis vitae ipsum viverra, condimentum ante vel, sagittis ex. Maecenas placerat odio libero, id interdum turpis fermentum at. + +Praesent faucibus nulla eget vehicula accumsan. Nulla elementum ante a nibh venenatis hendrerit. Proin nec nunc mattis, imperdiet nisl rutrum, sollicitudin libero. Nullam bibendum dignissim faucibus. Sed eget tortor vitae sapien cursus faucibus nec et lectus. Duis pharetra non odio id consectetur. Suspendisse at est sem. Nunc mauris ligula, ultrices id ante non, venenatis mattis erat. Vivamus sit amet viverra nisl. + +Sed cursus vel nisi in mattis. Nunc porttitor dictum leo ac euismod. Sed blandit ornare nunc id lobortis. Aenean convallis ligula at volutpat commodo. Vestibulum sit amet laoreet urna. Donec et pellentesque orci, ac egestas nulla. In accumsan venenatis porta. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Donec scelerisque, metus nec viverra pharetra, dolor libero dictum velit, id ullamcorper enim nisi eu nibh. Aliquam nec dapibus quam. Curabitur vulputate, libero sit amet tempor ullamcorper, libero purus congue quam, nec sollicitudin orci erat non massa. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. + +Aenean faucibus mollis placerat. Praesent lacinia venenatis turpis eu scelerisque. Nam tempus tortor a varius posuere. Cras ut viverra tortor. Cras facilisis mauris ut ante imperdiet, a malesuada justo luctus. Ut eu enim sit amet arcu porttitor pulvinar ac ac odio. Ut odio neque, molestie vitae ligula quis, dignissim viverra erat. In ullamcorper erat sed elementum varius. Sed in lacus maximus, euismod nisi vitae, tempus mi. Nunc tellus justo, auctor at luctus ac, feugiat sit amet dui. + +Donec imperdiet purus lorem, sed venenatis dolor finibus non. Aenean lacus nunc, elementum nec arcu eget, faucibus elementum turpis. Aliquam lacinia massa ac quam efficitur, et tincidunt eros pretium. Fusce condimentum mi vel pharetra egestas. Quisque consectetur nibh vel leo dignissim sollicitudin. Duis ultrices felis ipsum, sed maximus arcu ornare vitae. Curabitur porttitor ligula in turpis facilisis, id venenatis augue ultricies. Phasellus vel dolor id tellus finibus sodales ut quis nisi. Integer id orci cursus erat tincidunt sagittis non in nunc. Pellentesque ligula lacus, vestibulum eu ante vel, facilisis viverra massa. Sed ut tincidunt metus, vel tristique est. Ut et cursus justo. Ut ac porttitor eros, at dictum felis. Phasellus ornare nisi sit amet risus varius, sed sollicitudin nulla ornare. Donec aliquam ipsum urna. Aliquam id bibendum magna, quis venenatis diam. + +Duis tempor odio id iaculis egestas. Cras consequat neque ac posuere iaculis. Nulla tempus et nisi eu auctor. Vestibulum metus massa, dignissim ut metus eget, ullamcorper consectetur turpis. Integer vel est tellus. Ut ac vestibulum massa. Pellentesque nec venenatis erat. Nam vel pellentesque lectus. + +Morbi a placerat est. Ut eleifend ante ut placerat porta. Donec sagittis semper leo, ut scelerisque nisi imperdiet feugiat. Mauris purus turpis, consequat ut fringilla ac, cursus eget augue. Fusce arcu dolor, sagittis et facilisis ut, scelerisque non lacus. Aliquam sit amet eleifend tellus. Mauris id est luctus, iaculis tortor eget, gravida justo. Suspendisse at tellus nisl. Nullam felis erat, vehicula eu porttitor bibendum, pulvinar et dui. Sed molestie lacus nec sagittis rutrum. Aliquam erat volutpat. Nullam ut aliquet eros. Sed feugiat, massa id pharetra auctor, leo turpis condimentum purus, sit amet volutpat sem nunc sed nisi. + +Pellentesque feugiat ipsum at accumsan iaculis. Morbi et dui in lorem commodo hendrerit. Mauris tempor ex mollis mollis blandit. Cras eu turpis feugiat, suscipit velit quis, volutpat magna. Vestibulum varius ligula ut quam mollis, a volutpat nibh lobortis. Sed sodales euismod leo non suscipit. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nullam ultricies sagittis justo, sit amet lacinia eros tincidunt tristique. Etiam faucibus turpis in lacus efficitur, vitae rutrum magna porttitor. Cras finibus eros vel ante semper, facilisis vestibulum erat accumsan. Ut mollis dui ut commodo varius. In et sem malesuada erat egestas pellentesque. Maecenas pulvinar sodales risus, at euismod ligula aliquet in. Quisque fringilla malesuada dui vel cursus. Curabitur eu ex vulputate, pretium elit non, sodales sapien. Integer egestas facilisis odio et pretium. + +Integer eleifend, felis vitae faucibus tempus, tellus lectus placerat nunc, eu efficitur lacus mi vel nisl. Mauris commodo pretium feugiat. In aliquet nibh diam, ac egestas mauris consequat ut. Integer cursus, tortor pharetra pellentesque pulvinar, neque risus ultricies felis, et consequat felis eros at eros. Pellentesque fermentum velit ac sodales facilisis. Suspendisse vestibulum metus quis convallis lacinia. Donec in pharetra magna. Proin gravida dolor eget ligula lobortis sagittis. + +Nullam consectetur ut massa id ultrices. Fusce consectetur at eros at mollis. Donec nec nibh fringilla, porttitor ipsum eget, aliquam neque. Quisque suscipit tortor in dui commodo, sed venenatis augue cursus. Etiam feugiat purus id justo elementum placerat. Sed interdum dictum nibh at sodales. Maecenas lobortis, metus ac sagittis lacinia, elit arcu varius felis, quis facilisis magna elit in leo. Proin condimentum orci sit amet dignissim imperdiet. Fusce sed iaculis felis. Maecenas sodales non magna vitae rutrum. + +Nulla id ex massa. Sed vehicula sed quam non elementum. Aliquam luctus, enim vel molestie posuere, sem arcu laoreet justo, quis finibus nisl justo et magna. Vivamus malesuada elit in aliquam dignissim. Sed at tellus in orci vulputate ullamcorper. Integer magna sem, mattis id hendrerit non, tincidunt in est. Praesent posuere aliquet lobortis. Quisque euismod leo ut nisl pellentesque, et imperdiet dui dapibus. + +Sed a erat nec risus pulvinar venenatis. Integer ultrices eros at aliquet efficitur. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Quisque ac maximus ante. Sed sodales, nisi et sagittis accumsan, diam odio consequat elit, et mollis dolor turpis tincidunt metus. Proin justo quam, tincidunt id convallis a, molestie hendrerit massa. Etiam pharetra turpis eu ultrices mollis. + +Proin fermentum libero in purus cursus molestie. In varius magna eu ante maximus, eget rutrum felis iaculis. Maecenas hendrerit, diam eget vestibulum vehicula, est quam porta magna, a dictum urna mi ut libero. Vestibulum dictum quis lacus vitae eleifend. Integer sapien libero, pretium vitae euismod eget, semper eget ante. Vivamus mollis elementum odio vel hendrerit. Phasellus tristique, metus eget luctus tincidunt, ex enim faucibus ipsum, at dictum eros urna ac mi. Aenean imperdiet felis eu ultricies egestas. Vivamus fermentum convallis nisi, non sollicitudin felis posuere nec. Ut commodo sit amet felis semper dictum. Aenean accumsan, tellus id blandit aliquet, lorem nibh pellentesque mi, sit amet volutpat erat ligula ut est. Vivamus non posuere velit. Sed vel rutrum diam, non pulvinar nulla. Suspendisse quis gravida lectus, ac accumsan justo. Sed lobortis neque ante, a imperdiet nisl iaculis nec. \ No newline at end of file diff --git a/README.md b/README.md index 3710fd9e..108c7dd2 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map behavior are hand-written Lua; game data and graphics are decoded from a ROM supplied by the player. +And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. ***Reverse Engineering Causes Obsessive Mental Problems*** + +[Click Here for the AI Use Disclosure!](AIDisclosure.md) + > [!CAUTION] > **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project. diff --git a/assets/skins/gb_anim/README.md b/assets/skins/gb_anim/README.md new file mode 100644 index 00000000..7ce01506 --- /dev/null +++ b/assets/skins/gb_anim/README.md @@ -0,0 +1,10 @@ +# gb_anim -- bundled touch skin + +Bezel art and overlay layout from libretro's `common-overlays` +(`gamepads/gb_anim_portrait`), licensed CC-BY-4.0: +https://github.com/libretro/common-overlays + +`overlay.cfg` is the upstream `gb_big.cfg`, unmodified. It ships as the +reference skin for the RetroArch-overlay loader in +`src/core/TouchSkin.lua`: a full-device bezel, per-button press art, a +screen viewport, and page switching between the DMG and Color shells. diff --git a/assets/skins/gb_anim/img/gb_a_b.png b/assets/skins/gb_anim/img/gb_a_b.png new file mode 100644 index 00000000..165b6fd9 Binary files /dev/null and b/assets/skins/gb_anim/img/gb_a_b.png differ diff --git a/assets/skins/gb_anim/img/gb_back.png b/assets/skins/gb_anim/img/gb_back.png new file mode 100644 index 00000000..f0f10cf2 Binary files /dev/null and b/assets/skins/gb_anim/img/gb_back.png differ diff --git a/assets/skins/gb_anim/img/gb_down.png b/assets/skins/gb_anim/img/gb_down.png new file mode 100644 index 00000000..6f925d92 Binary files /dev/null and b/assets/skins/gb_anim/img/gb_down.png differ diff --git a/assets/skins/gb_anim/img/gb_left.png b/assets/skins/gb_anim/img/gb_left.png new file mode 100644 index 00000000..a4e340e4 Binary files /dev/null and b/assets/skins/gb_anim/img/gb_left.png differ diff --git a/assets/skins/gb_anim/img/gb_right.png b/assets/skins/gb_anim/img/gb_right.png new file mode 100644 index 00000000..619d8538 Binary files /dev/null and b/assets/skins/gb_anim/img/gb_right.png differ diff --git a/assets/skins/gb_anim/img/gb_start_select.png b/assets/skins/gb_anim/img/gb_start_select.png new file mode 100644 index 00000000..0c1bdaec Binary files /dev/null and b/assets/skins/gb_anim/img/gb_start_select.png differ diff --git a/assets/skins/gb_anim/img/gb_up.png b/assets/skins/gb_anim/img/gb_up.png new file mode 100644 index 00000000..d13739cd Binary files /dev/null and b/assets/skins/gb_anim/img/gb_up.png differ diff --git a/assets/skins/gb_anim/img/gbc_a.png b/assets/skins/gb_anim/img/gbc_a.png new file mode 100644 index 00000000..f83705d3 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_a.png differ diff --git a/assets/skins/gb_anim/img/gbc_b.png b/assets/skins/gb_anim/img/gbc_b.png new file mode 100644 index 00000000..e506fba4 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_b.png differ diff --git a/assets/skins/gb_anim/img/gbc_back.png b/assets/skins/gb_anim/img/gbc_back.png new file mode 100644 index 00000000..20648bd5 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_back.png differ diff --git a/assets/skins/gb_anim/img/gbc_down.png b/assets/skins/gb_anim/img/gbc_down.png new file mode 100644 index 00000000..8d310d48 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_down.png differ diff --git a/assets/skins/gb_anim/img/gbc_left.png b/assets/skins/gb_anim/img/gbc_left.png new file mode 100644 index 00000000..d7ac3998 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_left.png differ diff --git a/assets/skins/gb_anim/img/gbc_right.png b/assets/skins/gb_anim/img/gbc_right.png new file mode 100644 index 00000000..029520c9 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_right.png differ diff --git a/assets/skins/gb_anim/img/gbc_start_select.png b/assets/skins/gb_anim/img/gbc_start_select.png new file mode 100644 index 00000000..688d1903 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_start_select.png differ diff --git a/assets/skins/gb_anim/img/gbc_up.png b/assets/skins/gb_anim/img/gbc_up.png new file mode 100644 index 00000000..cc0fb248 Binary files /dev/null and b/assets/skins/gb_anim/img/gbc_up.png differ diff --git a/assets/skins/gb_anim/img/menu.png b/assets/skins/gb_anim/img/menu.png new file mode 100644 index 00000000..e27e7538 Binary files /dev/null and b/assets/skins/gb_anim/img/menu.png differ diff --git a/assets/skins/gb_anim/img/rotate.png b/assets/skins/gb_anim/img/rotate.png new file mode 100644 index 00000000..060c07bb Binary files /dev/null and b/assets/skins/gb_anim/img/rotate.png differ diff --git a/assets/skins/gb_anim/overlay.cfg b/assets/skins/gb_anim/overlay.cfg new file mode 100644 index 00000000..bfc2e254 --- /dev/null +++ b/assets/skins/gb_anim/overlay.cfg @@ -0,0 +1,89 @@ +overlays = 2 + +overlay0_name = "GameBoy" +overlay0_overlay = img/gb_back.png +overlay0_full_screen = true +overlay0_normalized = true +overlay0_range_mod = 1.0 +overlay0_alpha_mod = 0.001 +overlay0_viewport = "0.0,0.0,1.0,0.505" +overlay0_viewport_fill = true + +overlay1_name = "GameBoyColor" +overlay1_overlay = img/gbc_back.png +overlay1_full_screen = true +overlay1_normalized = true +overlay1_range_mod = 1.0 +overlay1_alpha_mod = 0.001 +overlay1_viewport = "0.0,0.0,1.0,0.505" +overlay1_viewport_fill = true + +# GameBoy +overlay0_descs = 18 + +overlay0_desc0 = "left,0.12778,0.73417,radial,0.09630,0.04635" +overlay0_desc0_overlay = img/gb_left.png +overlay0_desc1 = "right,0.35370,0.73417,radial,0.09630,0.04635" +overlay0_desc1_overlay = img/gb_right.png +overlay0_desc2 = "up,0.24074,0.67063,radial,0.08241,0.05417" +overlay0_desc2_overlay = img/gb_up.png +overlay0_desc3 = "down,0.24074,0.79771,radial,0.08241,0.05417" +overlay0_desc3_overlay = img/gb_down.png +overlay0_desc4 = "left|up,0.09259,0.65188,rect,0.06481,0.03646" +overlay0_desc5 = "right|up,0.38704,0.65188,rect,0.06481,0.03646" +overlay0_desc6 = "left|down,0.09259,0.81750,rect,0.06481,0.03646" +overlay0_desc7 = "right|down,0.38704,0.81750,rect,0.06481,0.03646" +overlay0_desc8 = "a,0.87407,0.72417,radial,0.08889,0.05000" +overlay0_desc8_overlay = img/gb_a_b.png +overlay0_desc9 = "b,0.68148,0.76584,radial,0.08889,0.05000" +overlay0_desc9_overlay = img/gb_a_b.png +overlay0_desc10 = "a|b,0.77037,0.73417,radial,0.02963,0.01667" +overlay0_desc11 = "a|b,0.78518,0.75584,radial,0.02963,0.01667" +overlay0_desc12 = "start,0.66666,0.93000,radial,0.07037,0.03958" +overlay0_desc12_overlay = img/gb_start_select.png +overlay0_desc13 = "select,0.33333,0.93000,radial,0.07037,0.03958" +overlay0_desc13_overlay = img/gb_start_select.png +overlay0_desc14 = "menu_toggle,0.05000,0.52800,radial,0.041296,0.02323" +overlay0_desc14_overlay = img/menu.png +overlay0_desc15 = "overlay_next,0.95000,0.52800,radial,0.041296,0.02323" +overlay0_desc15_overlay = img/rotate.png +overlay0_desc15_next_target = "GameBoyColor" +overlay0_desc16 = "rewind,0.05000,0.97500,radial,0.041296,0.02323" +overlay0_desc16_overlay = +overlay0_desc17 = "hold_fast_forward,0.95000,0.97500,radial,0.041296,0.02323" +overlay0_desc17_overlay = + +# GameBoyColor +overlay1_descs = 18 + +overlay1_desc0 = "left,0.14078,0.73417,radial,0.08530,0.04635" +overlay1_desc0_overlay = img/gbc_left.png +overlay1_desc1 = "right,0.34270,0.73417,radial,0.08530,0.04635" +overlay1_desc1_overlay = img/gbc_right.png +overlay1_desc2 = "up,0.24074,0.67863,radial,0.08241,0.04617" +overlay1_desc2_overlay = img/gbc_up.png +overlay1_desc3 = "down,0.24074,0.78971,radial,0.08241,0.04617" +overlay1_desc3_overlay = img/gbc_down.png +overlay1_desc4 = "left|up,0.09259,0.65188,rect,0.06481,0.03646" +overlay1_desc5 = "right|up,0.38704,0.65188,rect,0.06481,0.03646" +overlay1_desc6 = "left|down,0.09259,0.81750,rect,0.06481,0.03646" +overlay1_desc7 = "right|down,0.38704,0.81750,rect,0.06481,0.03646" +overlay1_desc8 = "a,0.87407,0.72417,radial,0.08889,0.05000" +overlay1_desc8_overlay = img/gbc_a.png +overlay1_desc9 = "b,0.68148,0.76584,radial,0.08889,0.05000" +overlay1_desc9_overlay = img/gbc_b.png +overlay1_desc10 = "a|b,0.77037,0.73417,radial,0.02963,0.01667" +overlay1_desc11 = "a|b,0.78518,0.75584,radial,0.02963,0.01667" +overlay1_desc12 = "start,0.66666,0.93000,radial,0.07037,0.03958" +overlay1_desc12_overlay = img/gbc_start_select.png +overlay1_desc13 = "select,0.33333,0.93000,radial,0.07037,0.03958" +overlay1_desc13_overlay = img/gbc_start_select.png +overlay1_desc14 = "menu_toggle,0.05000,0.52800,radial,0.041296,0.02323" +overlay1_desc14_overlay = img/menu.png +overlay1_desc15 = "overlay_next,0.95000,0.52800,radial,0.041296,0.02323" +overlay1_desc15_overlay = img/rotate.png +overlay1_desc15_next_target = "GameBoy" +overlay1_desc16 = "rewind,0.05000,0.97500,radial,0.041296,0.02323" +overlay1_desc16_overlay = +overlay1_desc17 = "hold_fast_forward,0.95000,0.97500,radial,0.041296,0.02323" +overlay1_desc17_overlay = diff --git a/assets/skins/tv_crt/README.md b/assets/skins/tv_crt/README.md new file mode 100644 index 00000000..fabdb561 --- /dev/null +++ b/assets/skins/tv_crt/README.md @@ -0,0 +1,10 @@ +# tv_crt -- bundled desktop bezel + +CRT television border from libretro's `common-overlays` +(`borders/tv-integer.cfg` + `borders/img/tv-integer.png`), licensed +CC-BY-4.0: https://github.com/libretro/common-overlays + +`overlay.cfg` is the upstream file, unmodified. It is the reference +DESKTOP skin: 1920x1080, `descs = 0` (pure decoration, no touch buttons), +and a `viewport` naming the transparent screen hole, so the Game Boy +picture is fitted into the TV's tube instead of the whole window. diff --git a/assets/skins/tv_crt/img/tv-integer.png b/assets/skins/tv_crt/img/tv-integer.png new file mode 100644 index 00000000..988c8766 Binary files /dev/null and b/assets/skins/tv_crt/img/tv-integer.png differ diff --git a/assets/skins/tv_crt/overlay.cfg b/assets/skins/tv_crt/overlay.cfg new file mode 100644 index 00000000..cc299bc8 --- /dev/null +++ b/assets/skins/tv_crt/overlay.cfg @@ -0,0 +1,6 @@ +overlays = 1 +overlay0_overlay = img/tv-integer.png +overlay0_full_screen = true +overlay0_descs = 0 +overlay0_viewport = "0.2335,0.0855,0.5335,0.830" +overlay0_viewport_fill = true diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index ffbfb585..094bc011 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -702,6 +702,23 @@ M.MT_MOON_B2F = { return false end, talk = { + -- MtMoonB2FSuperNerdText: once beaten his line turns on the fossils + -- (scripts/MtMoonB2F.asm:187), which the header's flat `after` can't hold + TEXT_MTMOONB2F_SUPER_NERD = function(game, ow, npc, done) + if not superNerdBeaten(ow) then + engageSuperNerd(game, ow, done) + return + end + local TextBox = require("src.render.TextBox") + local t = game.data.text + local flags = game.save.flags + local line = (flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL) + and (t._MtMoonB2FSuperNerdTheresAPokemonLabText + or "Far away, on\nCINNABAR ISLAND,\nthere's a POKéMON\nLAB.") + or (t._MtMoonB2fSuperNerdEachTakeOneText + or "We'll each take\none!\nNo being greedy!") + game.stack:push(TextBox.new(game, line, done)) + end, TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil( "DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"), TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil( diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 769309e6..f6a759d6 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -118,33 +118,36 @@ M.ROUTE_15_GATE_2F = { M.MT_MOON_POKECENTER = { talk = { - TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = function(game, ow, npc, done) - local t = text(game) - if game.save.flags.EVENT_BOUGHT_MAGIKARP then - push(game, t._MtMoonPokecenterMagikarpSalesmanNoRefundsText - or "Well, I don't\ngive refunds!", done) - return - end - ask(game, t._MtMoonPokecenterMagikarpSalesmanOfferText - or "MAGIKARP! A\nsteal at ¥500!\nWant one?", function(yes) - if not yes then - push(game, t._MtMoonPokecenterMagikarpSalesmanNoText - or "No? I'm only\nselling today!", done) - return - end - if game.save.money < 500 then - push(game, t._MtMoonPokecenterMagikarpSalesmanNoMoneyText - or "You'll need more\nmoney than that!", done) - return - end - game.save.money = game.save.money - 500 - game.save.flags.EVENT_BOUGHT_MAGIKARP = true - local Commands = require("src.script.Commands") - Commands.give_pokemon({ save = game.save, game = game, overworld = ow }, - "MAGIKARP", 5) - push(game, t._GotMonText or "{PLAYER} got\n{RAM:wNameBuffer}!", done) - end) - end, + -- command rows, not a Lua handler: give_pokemon needs a runner to AskName (#1407) + TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = { + { "check_flag", "EVENT_BOUGHT_MAGIKARP" }, + { "jump_if_true", "no_refunds" }, + -- MONEY_BOX goes up between the offer and YesNoChoice -- MtMoonPokecenter.asm:31 + { "text_opts", { money = true } }, + { "ask", "_MtMoonPokecenterMagikarpSalesmanIGotADealText" }, + { "jump_if_false", "declined" }, + { "check_money", 500 }, + { "jump_if_false", "no_money" }, + { "give_pokemon", "MAGIKARP", 5 }, + -- MtMoonPokecenter.asm:49 `jr nc, .done`: a refused gift is never charged + { "jump_if_false", "box_full" }, + { "take_money", 500 }, + { "set_flag", "EVENT_BOUGHT_MAGIKARP" }, + { "text_sound", "Get_Item1" }, + { "show_text", "_GotMonText", { RAM = "MAGIKARP" } }, + { "jump", "end" }, + { "label", "box_full" }, + { "show_text", "_BoxIsFullText" }, + { "jump", "end" }, + { "label", "declined" }, + { "show_text", "_MtMoonPokecenterMagikarpSalesmanNoText" }, + { "jump", "end" }, + { "label", "no_money" }, + { "show_text", "_MtMoonPokecenterMagikarpSalesmanNoMoneyText" }, + { "jump", "end" }, + { "label", "no_refunds" }, + { "show_text", "_MtMoonPokecenterMagikarpSalesmanNoRefundsText" }, + }, }, } diff --git a/docs/new-features.md b/docs/new-features.md index 2fca8c70..64615d10 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -11,28 +11,10 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Persistent custom options** stored separately from game saves * **Optional widescreen battle layout** * **Mobile touch controls** with editable layouts, vibration, and orientation settings -* **Translation and custom font support** -* **Built-in save editor** for parties, boxes, items, events, maps, and Pokédex data -* **Tiled map editing tools** for mod authors +* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders * **Pokédex diploma and printer image exports** -* **Community mod browser** -* **Soft reset button combination** -* **Keyboard and controller rebinding** -* **Mod profiles** with separate mod settings and save slots -* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage -* **Improved launcher and save editor UI**, including background downloads and update checks -* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends -## Pokémon Gold (Gen 2) +## Gen 2 Specifics -* **COLOR, zoom, tilt, GBC FX, and quick save/load** -* **UI that stays fixed while the overworld zooms** -* **Border-block surrounds** for maps smaller than the screen -* **Gold-specific launcher options** -* **Optional widescreen battle layout** -* **Skippable trade animation** with B or START -* **QUIT and EXIT GAME** from the menus * **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check` * **Followers** for mods, plus Gen 2-only registries and hooks -* **On-screen touch pad** and controller SELECT for registered items -* **Older mods keep loading** after the sandbox change, through per-mod compat stand-ins for the pre-sandbox globals diff --git a/docs/skin-studio.md b/docs/skin-studio.md new file mode 100644 index 00000000..d7a834a4 --- /dev/null +++ b/docs/skin-studio.md @@ -0,0 +1,179 @@ +# Touch skins and the Skin Studio + +A **skin** replaces the on-screen controls wholesale: a bezel image, a +control layout, and the rectangle the Game Boy screen is drawn into. Engine: +`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua` +(draw and input), `src/render/Renderer.lua` (the screen viewport), +`src/ui/SkinStudio.lua` (the desktop editor). Tests: +`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`, +`tests/engine/launcher_skins_tab.lua`. + +Skins are picked in the launcher's **Skins** tab, which also imports them and +opens the studio. `options.touchControls.skin` holds the folder name. + +## Formats + +Two load. `skin.lua` wins when a folder has both. + +**RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads +as-is. Supported keys: + +| Key | Meaning | +| --- | --- | +| `overlays` | page count | +| `overlayN_name` | page name, the target of `next_target` | +| `overlayN_overlay` | bezel image | +| `overlayN_full_screen` | stretch the page to the window | +| `overlayN_rect` | page placement, default `0,0,1,1` | +| `overlayN_aspect_ratio` | fallback aspect when not full screen | +| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults | +| `overlayN_viewport` | `x,y,w,h`, the screen cutout | +| `overlayN_viewport_fill` | parsed; the engine always fits, see below | +| `overlayN_descM` | `binds,x,y,shape,range_x,range_y` | +| `overlayN_descM_overlay` | control art | +| `overlayN_descM_next_target` | page to switch to | +| `overlayN_descM_range_mod`, `_alpha_mod` | per-control overrides | +| `overlayN_descM_reach_x/_y/_up/_down/_left/_right` | hitbox reach | + +`x,y` is the centre and `range_x,range_y` are half extents, both normalized. +Hitboxes are `radial` or `rect`. Pipe-separated binds (`left|down`) are one +control that holds both. A `nul` desc is decoration: it draws and never +captures a touch. + +Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every +image sits at the overlay opacity, and a pressed control's image swaps to +`opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1 +fades it out, and both directions read as a press animation. + +**Native `skin.lua`.** This module's own model written back out: one Lua +table, no flat key space, and a separate `imagePressed` per control that a +`.cfg` cannot express. Loaded with an empty environment, so a skin authored by +a stranger cannot reach `love` or `io`. Sizes here are full width and height +rather than RetroArch's half extents, because that is what an editor's numeric +fields mean. + +```lua +return { + name = "my_skin", + pages = { + { + name = "main", + image = "img/bezel.png", + fullScreen = true, + viewport = { x = 0.0, y = 0.0, w = 1.0, h = 0.5, fill = false }, + controls = { + { bind = "a", x = 0.87, y = 0.72, w = 0.18, h = 0.10, + shape = "radial", image = "img/a.png", imagePressed = "img/a_down.png" }, + }, + }, + }, +} +``` + +## Bindable actions + +The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`, +`left`, `right`. + +Engine hotkeys, handled in `Game:touchSkinHotkey`: + +| Bind | Effect | +| --- | --- | +| `overlay_next`, `overlay_previous` | switch page, honouring `next_target` | +| `hold_fast_forward`, `fast_forward` | fast forward while held | +| `toggle_fast_forward` | step the speed option | +| `reset` | soft reset to the title | +| `menu_toggle` | open OPTIONS | + +`screenshot`, `pause_toggle` and `exit_emulator` are recognised but have no +handler yet: a control bound to them draws and does nothing. Anything else, +`rewind` included, is not in the bind table at all, so the control falls back +to decoration and never captures a touch. + +As an extension to the format, `key:` presses any keyboard key, which is +how a skin button reaches a mod hotkey. + +## The screen viewport + +`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy +screen keeps its whole-pixel scale and letterboxes inside that rect rather than +stretching to it, so a bezel gets an exact 160x144 picture; `viewport_fill` is +parsed but does not stretch. `overlayN_viewport_expand = true` is an extension +that lets a widescreen bezel take the filling survey-zoom world view instead. + +A viewport also implies the faithful-ratio lock. Without it the world pass +expands to fill the cutout and you get more map instead of a Game Boy screen. + +Border art often ships with a transparent hole and no `viewport` key. **Detect +screen from bezel** in the studio measures the hole out of the art's alpha +channel and writes the rect. + +## Bezels versus pads + +A skin whose active page binds nothing is a frame rather than a pad: a TV +surround, a handheld shell, a Super Game Boy border. Those draw on **desktop** +as well, where the touch overlay itself does not, and a gamepad does not hide +them. Anything that binds a button still follows the usual mobile / +`POKEPORT_TOUCH` rule. + +## Installing + +Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on +the launcher window while the Skins tab is open. A zip is mounted in place, so +there is nothing to unpack. The folder needs one `skin.lua` or `.cfg` +(`overlay.cfg` is preferred when there are several) and the images it names. + +Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0: + +| Skin | Source | Shape | +| --- | --- | --- | +| `gb_anim` | `gamepads/gb_anim_portrait` | handheld shell, working buttons, two pages | +| `tv_crt` | `borders/tv-integer` | CRT television frame, no buttons | + +Attribution lives in each folder's `README.md`. `tv_crt` is a photograph of a +real television: CC-BY-4.0 upstream, but treat it as a test asset rather than +shipping branding. + +## The studio + +Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open +that skin. Desktop only: the launcher does not offer it on Android or iOS, +because it wants a mouse, typed coordinates and room for an inspector. + +**Canvas.** A mock device at a chosen preset, so a phone skin is authored at +phone proportions on a desktop monitor. + +| Preset | Size | +| --- | --- | +| Phone portrait / landscape | 1080x1920, 1920x1080 | +| Tablet portrait / landscape | 1536x2048, 2048x1536 | +| Steam Deck | 1280x800 | +| Desktop 1080p | 1920x1080 | +| Ultrawide 21:9 | 2560x1080 | +| Super Game Boy border | 256x224 | + +The Super Game Boy preset locks the viewport to the real screen window, +160x144 at (48,40), so an SGB border cannot be drawn out of register. + +**Editing.** Click a control to select it, drag to move, eight handles to +resize. X / Y / W / H are in canvas pixels, so a control can be typed to the +coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and +pressed images are per control; the bezel, the pages and the screen cutout are +per page. The cutout is itself a draggable element with a 10:9 lock. Drop a PNG +or JPG on the window to import art into the skin. + +**Testing.** **Test** makes the canvas live: clicking presses real Game Boy +buttons and the footer reports what is held. **Play** saves the skin, selects +it, and boots the game with it. + +**Saving.** **Save** writes `skins//skin.lua` and copies every image the +skin names, so the folder stands alone. **Export** packs it as one zip +(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the +images, and the original `.cfg` when it came from one. An exported skin drops +straight back into `skins/` and still opens in RetroArch. + +## Not implemented + +RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types. +Image assignment cycles through art already in the skin folder; there is no +file browser, so new art arrives by drag and drop. diff --git a/docs/updater.md b/docs/updater.md index 7815874a..5935b617 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -120,6 +120,7 @@ bundled game, in that case. ## Known limitations + - **`love.run` persists across handoff.** By the time `chainload` runs, the bundled `love.run` has already returned its stepper to LOVE; redefining the global `love.run` from the payload's `main.lua` does not affect the loop diff --git a/main.lua b/main.lua index 4fbd44f2..10a07426 100644 --- a/main.lua +++ b/main.lua @@ -36,7 +36,7 @@ do end end -local Game, EditorApp, Importer, TouchEditor +local Game, EditorApp, Importer, TouchEditor, Studio -- #887: quit-to-launcher state, shared by love.load and love.quit (both need -- it, so it is declared here rather than next to love.quit). @@ -255,7 +255,43 @@ function closeTouchControlsEditor() end end -local function bootGame(version) +-- ------------------------------------------------------------ skin studio +local studioHost +local closeSkinStudio +local bootGame + +local function openSkinStudio(version, skinId) + local SkinStudio = require("src.ui.SkinStudio") + if not SkinStudio.available_desktop() then return end + studioHost = Importer + if Importer and Importer.prepareOverlayHandoff then + Importer:prepareOverlayHandoff() + end + Importer = nil + Studio = SkinStudio + Studio.load({ + version = version, + skinId = skinId, + onClose = function() closeSkinStudio() end, + onPlay = function(v) + closeSkinStudio() + Importer = nil + bootGame(v or version) + end, + }) +end + +function closeSkinStudio() + if Studio and Studio.unload then Studio.unload() end + Studio = nil + Importer = studioHost + studioHost = nil + if Importer and Importer.resumeAfterOverlay then + Importer:resumeAfterOverlay() + end +end + +function bootGame(version) -- The launcher hands us the chosen game (Red / Blue / Yellow / Gold); -- scripted and headless runs fall back to POKEPORT_VERSION, then Red. -- Set the active version and overlay its extracted cache BEFORE anything @@ -456,6 +492,8 @@ function love.load(args) forceImport = forceImport, onEditSave = openEditor, onEditTouchControls = openTouchControlsEditor, + onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop() + and openSkinStudio or nil, }) end @@ -466,6 +504,7 @@ function love.update(dt) NxDisplay.sync() if editorMode then return EditorApp.update(dt) end if TouchEditor then return TouchEditor.update(dt) end + if Studio then return Studio.update(dt) end if Importer then return Importer:update(dt) end -- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly @@ -522,6 +561,12 @@ function love.draw() HostDisplay.endFrame("touch_editor", TouchEditor) return result end + if Studio then + HostDisplay.beginFrame("skin_studio", Studio) + local result = Studio.draw() + HostDisplay.endFrame("skin_studio", Studio) + return result + end if Importer then GameViewport.reset() HostDisplay.beginFrame("launcher", Importer) @@ -555,12 +600,13 @@ end function love.keypressed(key, scancode, isrepeat) if editorMode then return EditorApp.keypressed(key) end if TouchEditor then return TouchEditor.keypressed(key) end + if Studio then return Studio.keypressed(key) end if Importer then return Importer:keypressed(key) end Game:keypressed(key) end function love.keyreleased(key) - if editorMode or TouchEditor then return end + if editorMode or TouchEditor or Studio then return end if Importer then return end Game:keyreleased(key) end @@ -789,6 +835,7 @@ function love.wheelmoved(x, y) return end if TouchEditor then return end + if Studio then return Studio.wheelmoved(x, y) end if Importer then return end Game:wheelmoved(x, y) end @@ -826,6 +873,7 @@ function love.mousepressed(x, y, button, istouch) if love.system.getOS() == "Android" then return end return TouchEditor.mousepressed(x, y, button) end + if Studio then return Studio.mousepressed(x, y, button) end if Importer then -- love.touchpressed already forwards the primary touch into FlexLove for -- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both @@ -860,6 +908,7 @@ function love.mousereleased(x, y, button, istouch) if love.system.getOS() == "Android" then return end return TouchEditor.mousereleased(x, y, button) end + if Studio then return Studio.mousereleased(x, y, button) end if Importer then return end if editorMode and EditorApp.mousereleased then return EditorApp.mousereleased(x, y, button) @@ -877,6 +926,7 @@ function love.mousemoved(x, y, dx, dy, istouch) if love.system.getOS() == "Android" then return end return TouchEditor.mousemoved(x, y) end + if Studio then return Studio.mousemoved(x, y) end if editorMode or Importer then return end if mouseTouch then if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end @@ -887,6 +937,7 @@ end function love.textinput(text) if TouchEditor then return end + if Studio then return Studio.textinput(text) end if Importer then return Importer:textinput(text) end if editorMode and EditorApp.textinput then return EditorApp.textinput(text) @@ -964,6 +1015,7 @@ function love.filedropped(file) if editorMode and EditorApp and EditorApp.filedropped then return EditorApp.filedropped(file) end + if Studio then return Studio.filedropped(file) end if Importer then Importer:filedropped(file) end end diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index bda28b39..58f80d59 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -24,6 +24,7 @@ local Runtime = require("src.mods.Runtime") local BattleSafety = require("src.battle.BattleSafety") local Screens = require("src.ui.Screens") local Status = require("src.battle.Status") +local Theme = require("src.ui.Theme") local Timing = require("src.core.Timing") local TrainerAI = require("src.battle.TrainerAI") local TurnOrder = require("src.battle.TurnOrder") @@ -983,8 +984,8 @@ end -- Message that opens YES/NO once typed out, keeping the text visible -- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice). -function BattleState:sayChoice(text, onChoose) - table.insert(self.queue, { text = text, choice = onChoose }) +function BattleState:sayChoice(text, onChoose, opts) + table.insert(self.queue, { text = text, choice = onChoose, choiceOpts = opts }) end function BattleState:act(fn) @@ -1485,7 +1486,7 @@ function BattleState:updateQueue() local fn = item.choice battle.current = nil fn(yes) - end)) + end, item.choiceOpts)) return true end if item and item.auto then @@ -1843,14 +1844,7 @@ function BattleState:enter() self:say(self:sendOutText(self.player.name)) -- then the POOF plays and the mon appears with its cry -- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry) - table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false }) - self:act(function() - self.sendingOut = false - -- SendOutMon (core.asm:1757-1762): after the poof the mon grows - -- out of the ball (AnimateSendingOutMon at hlcoord 4,11) - self:startGrowIn(self.player) - self:waitSfxNext(self:playEntranceCry(self.player)) - end) + self:queueSendOutAnim(true) self:markParticipant() end self.phase = "messages" @@ -2680,13 +2674,7 @@ function BattleState:resolveSwitch(newMon) sendOutMonCursors(self) self.sendingOut = true self:sayNext(self:sendOutText(self.player.name)) - self:animNext("POOF_ANIM", false) - self:actNext(function() - self.sendingOut = false - -- SendOutMon (core.asm:1757-1762): poof, then the grow-in - self:startGrowIn(self.player) - self:waitSfxNext(self:playEntranceCry(self.player)) - end) + self:queueSendOutAnim(false) end) self:act(function() self:executeAction(self.enemy, self.player, self:enemyAction()) @@ -3231,6 +3219,50 @@ function BattleState:startGrowIn(battler) table.insert(self.queue, self.nextInsert, { wait = 12 }) end +-- SendOutMon branches on IsThisPartyMonStarterPikachu before the animation: +-- the starter gets no ball and no grow-in -- pokeyellow core.asm:1798-1819 +function BattleState:starterPikachuSendOut() + if not require("src.core.GameVersion").isYellow() then return false end + local mon = self.player and self.player.mon + return require("src.world.PikachuFollower") + .isStarterPikachu(self.game.save, mon) +end + +-- StarterPikachuBattleEntranceAnimation: the back pic walks in from hlcoord +-- 0,5, one column every 2 frames -- engine/battle/pikachu_entrance_anim.asm:1 +function BattleState:startPikachuEntrance() + self:slidePic("playerMon", -56, 0, 8, 2) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { wait = 16 }) + self:actNext(function() + self:slidePic("playerMon") + self:waitSfxNext(self:playEntranceCry(self.player)) + end) +end + +-- Player send-out tail: POOF_ANIM + AnimateSendingOutMon (core.asm:1757-1762), +-- or the starter Pikachu entrance instead (pokeyellow core.asm:1798-1819) +function BattleState:queueSendOutAnim(append) + local pikachu = self:starterPikachuSendOut() + if not pikachu then + if append then + table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false }) + else + self:animNext("POOF_ANIM", false) + end + end + local fn = function() + self.sendingOut = false + if pikachu then + self:startPikachuEntrance() + else + self:startGrowIn(self.player) + self:waitSfxNext(self:playEntranceCry(self.player)) + end + end + if append then self:act(fn) else self:actNext(fn) end +end + -- Should the low-health alarm sound this frame? pokered keys it off -- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets -- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is @@ -3289,17 +3321,18 @@ end -- _ScrollTrainerPicAfterBattle (engine/battle/scroll_draw_trainer_pic.asm) -- brings the beaten foe back in from the right one column every 4 frames. -- picOff holds the live programs by slot -- "foe" = the enemy trainer pic, --- "back" = the player's back pic -- as a screen-pixel x offset stepped --- toward `to`; updateFx advances them, drawPicsLayer adds them, and the --- queue rows that start them park a { wait } of the matching length. Call --- with no target to clear a slot (#317, #282). -function BattleState:slidePic(slot, from, to, step) +-- "back" = the player's back pic, "playerMon" = the player's mon back pic -- +-- as a screen-pixel x offset stepped toward `to`; updateFx advances them, +-- drawPicsLayer adds them, and the queue rows that start them park a +-- { wait } of the matching length. Call with no target to clear +-- a slot (#317, #282). +function BattleState:slidePic(slot, from, to, step, hold) self.picOff = self.picOff or {} if to == nil then self.picOff[slot] = nil return end - self.picOff[slot] = { x = from or 0, to = to, step = step or 4 } + self.picOff[slot] = { x = from or 0, to = to, step = step or 4, hold = hold } end -- the live x offset for a pic slot, 0 when nothing is sliding @@ -3317,10 +3350,18 @@ function BattleState:updateFx() -- the battle slot) until its owner clears the slot if self.picOff then for _, p in pairs(self.picOff) do - if p.x < p.to then - p.x = math.min(p.to, p.x + p.step) - elseif p.x > p.to then - p.x = math.max(p.to, p.x - p.step) + local move = true + if p.hold then + p.held = (p.held or 0) + 1 + move = p.held >= p.hold + if move then p.held = 0 end + end + if move then + if p.x < p.to then + p.x = math.min(p.to, p.x + p.step) + elseif p.x > p.to then + p.x = math.max(p.to, p.x - p.step) + end end end end @@ -4070,8 +4111,8 @@ function BattleState:awardExp() end local function applyShare(mon, split, announce) local playerId = self.game.save.player and self.game.save.player.id - local traded = mon.otId ~= nil and playerId ~= nil - and mon.otId ~= playerId or mon.traded == true and mon.otId == nil + local traded = mon.traded == true + or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId) local levels, gained = Experience.apply(self.data, mon, self.enemy.def, self.enemy.mon.level, self.kind == "trainer", split, traded) @@ -4231,7 +4272,7 @@ function BattleState:enemyMonFainted() end end, }) - end) + end, { box = Theme.trainerSwitchBox }) end self:act(function() local previous = self.enemy @@ -4295,12 +4336,7 @@ function BattleState:enemyMonFainted() sendOutMonCursors(self) self.sendingOut = true self:sayNext(self:sendOutText(self.player.name)) - self:animNext("POOF_ANIM", false) - self:actNext(function() - self.sendingOut = false - self:startGrowIn(self.player) - self:waitSfxNext(self:playEntranceCry(self.player)) - end) + self:queueSendOutAnim(false) end) return end @@ -4491,13 +4527,7 @@ function BattleState:openReplacementMenu() sendOutMonCursors(self) self.sendingOut = true self:sayNext(self:sendOutText(self.player.name)) - self:animNext("POOF_ANIM", false) - self:actNext(function() - self.sendingOut = false - -- SendOutMon (core.asm:1757-1762): poof, then the grow-in - self:startGrowIn(self.player) - self:waitSfxNext(self:playEntranceCry(self.player)) - end) + self:queueSendOutAnim(false) end, }) end) @@ -5749,7 +5779,10 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) else local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(), pad, padL, s) - self:drawBattlerPic(self.player, dx + sx, dy + sy, s) + -- picOffset: StarterPikachuBattleEntranceAnimation walking the pic in + -- from the left -- engine/battle/pikachu_entrance_anim.asm:1 + self:drawBattlerPic(self.player, dx + sx + self:picOffset("playerMon"), + dy + sy, s) end end if clipped then diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua index 302b8f5c..e795b6c7 100644 --- a/src/battle/gen2/AnimRunner.lua +++ b/src/battle/gen2/AnimRunner.lua @@ -243,8 +243,9 @@ end C.incbgeffect = function(self, row) self.bg:incEffect(row[2]) end -C.battlergfx_1row = function(self) self:loadBattlerGfx(1) end -C.battlergfx_2row = function(self) self:loadBattlerGfx(2) end +-- anim_commands.asm:317: $d9 (anim_battlergfx_2row) dispatches to _1Row +C.battlergfx_1row = function(self) self:loadBattlerGfx(2) end +C.battlergfx_2row = function(self) self:loadBattlerGfx(1) end -- GetPokeBallWobble's answer, which the ball's own script then branches on. C.checkpokeball = function(self) diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index e2d5a47e..16ed674e 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -1868,7 +1868,11 @@ function Battle:useMove(attacker, defender, moveId) if status and (def.power or 0) == 0 then -- A refused primary status is a failed move (effect_commands.asm:3748, -- :6656); a refused secondary already animated and stays unmarked (:3752). - if Battle.AI_FAIL_STATUSES[status] + if self:statusRefusedByType(defender, def.type, status) then + self:markMissed() + self:emit({ kind = "message", + text = "It doesn't affect " .. self:monName(defender) .. "..." }) + elseif Battle.AI_FAIL_STATUSES[status] and self:aiRandomFail(attacker, defender) then self:markMissed() self:emit({ kind = "message", text = "But it failed!" }) @@ -1880,7 +1884,8 @@ function Battle:useMove(attacker, defender, moveId) and record.status or nil -- engine/battle/effect_commands.asm:6325 if secondary and (defender.hp or 0) > 0 - and not self:safeguarded(defender) then + and not self:safeguarded(defender) + and not self:statusRefusedByType(defender, def.type, secondary) then local chance = def.effectChance or 0 if chance > 0 and rand(self.random, 100) < chance then self:applyStatus(defender, secondary, attacker) @@ -2941,6 +2946,27 @@ end -- `source` is the battler that inflicted it, carried only so -- battle.status_inflicted can name it the way Gen 1's does. +-- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup, +-- and the poison pair also refuses a POISON-type target: effect_commands.asm +-- :5788 (paralyze), :3671 (poison), :3646 / :4019 (the secondary arms). +-- Sleep, confusion and stat changes are deliberately not gated. +function Battle:statusRefusedByType(defender, moveType, status) + if not (status == "paralyze" or status == "poison" or status == "toxic") then + return false + end + local types = (self:speciesDef(defender) or {}).types or defender.types or {} + if moveType then + local matchups = self.data.type_chart and self.data.type_chart.matchups + if Damage.typeMultiplier(moveType, types, matchups) == 0 then return true end + end + if status == "poison" or status == "toxic" then + for _, t in ipairs(types) do + if t == "POISON" then return true end + end + end + return false +end + function Battle:applyStatus(mon, status, source) if (mon.hp or 0) <= 0 then return false end -- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives @@ -3202,6 +3228,7 @@ end -- player's own. function Battle:isOutsider(mon) local playerId = self.save and self.save.player and self.save.player.id + if mon.traded == true then return true end if mon.otId == nil or playerId == nil then return false end return mon.otId ~= playerId end @@ -3363,25 +3390,33 @@ function Battle:awardExperience(loser) -- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is -- accepted and ignored rather than changing what is printed. `recipients`, -- `holders` and `halved` are the Gen 2 additions. - if not Runtime.wantsHook("battle.exp_award") then return vanillaAward() end - local alive = {} - for _, index in ipairs(participants) do - local mon = self.party[index] - if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end - end - local function applyShare(mon, split) - for index, candidate in ipairs(self.party) do - if candidate == mon then - return self:giveExperiencePass(loser, def, { index }, - math.max(1, split or 1), halved) + if Runtime.wantsHook("battle.exp_award") then + local alive = {} + for _, index in ipairs(participants) do + local mon = self.party[index] + if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end + end + local function applyShare(mon, split) + for index, candidate in ipairs(self.party) do + if candidate == mon then + return self:giveExperiencePass(loser, def, { index }, + math.max(1, split or 1), halved) + end end end + Runtime.call("battle.exp_award", vanillaAward, { + battle = self, participants = #participants, alive = alive, + applyShare = applyShare, recipients = participants, holders = holders, + halved = halved, loser = loser, + }) + else + vanillaAward() end - Runtime.call("battle.exp_award", vanillaAward, { - battle = self, participants = #participants, alive = alive, - applyShare = applyShare, recipients = participants, holders = holders, - halved = halved, loser = loser, - }) + + -- GiveExperiencePoints .done falls through ResetBattleParticipants into + -- AddBattleParticipant (engine/battle/core.asm:7116 and :3033). + self.participants = {} + if self.playerIndex then self.participants[self.playerIndex] = true end end -- The answer to a `choose-forget`: drop the move in `slot` and put the diff --git a/src/battle/gen2/Catching.lua b/src/battle/gen2/Catching.lua index 4521d34f..bfd54b30 100644 --- a/src/battle/gen2/Catching.lua +++ b/src/battle/gen2/Catching.lua @@ -347,14 +347,13 @@ function Catching.attempt(opts) -- that edits o.catchRate or o.status changes the roll exactly as it does -- on Red, and one that returns `caught, rate` replaces it outright. -- - -- `mon` and `def` are whatever the catch site supplied. Gold's battle - -- screen (src/ui/gen2/BattleState.lua) hands this module a FLAT opts table - -- -- hp, maxHp, catchRate, status, species -- rather than the mon and its - -- record, so both are nil there until it passes them; nil, not a stand-in - -- a mod would read as the real mon. Passing `battle = self.battle` and - -- `mon = enemy` with them is what puts the capture tail below on the real - -- catch: the Transform reload and the battle.catch_exp hook both hang off - -- the battle, and neither can be reached from a flat table. + -- `mon` and `def` are whatever the catch site supplied, and nil rather + -- than a stand-in a mod would read as the real mon when it supplied + -- neither. Gold's battle screen (src/ui/gen2/BattleState.lua) passes them + -- with `battle` alongside the flat hp/maxHp/catchRate/status fields, which + -- is what puts the capture tail below on the real catch: the Transform + -- reload and the battle.catch_exp hook both hang off the battle, and + -- neither can be reached from a flat table. caught, rate = Runtime.call("catch.rate", function(_, _, _, o) return Catching.vanillaAttempt(o) end, opts.ball or "POKE_BALL", opts.mon, opts.def, opts) diff --git a/src/battle/gen2/Mon.lua b/src/battle/gen2/Mon.lua index 4b79c460..5f515567 100644 --- a/src/battle/gen2/Mon.lua +++ b/src/battle/gen2/Mon.lua @@ -388,7 +388,8 @@ function Mon.stampOT(save, mon) mon.ot = mon.ot or player.name -- NpcTrade.lua:150: `ot` is what Breeding reads, `otName` what the summary prints. mon.otName = mon.otName or mon.ot - mon.otId = mon.otId or player.id + -- engine/battle/experience.asm:69: a traded mon keeps its own OT id + if not mon.traded then mon.otId = mon.otId or player.id end return mon end diff --git a/src/core/Game.lua b/src/core/Game.lua index 7c3f9aaa..1b6019e1 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -59,6 +59,9 @@ function Game:load() self.touchControls = TouchControls TouchControls:init() + TouchControls:setHotkeyHandler(function(action, pressed) + self:touchSkinHotkey(action, pressed) + end) self.renderer = Renderer Renderer:init() @@ -185,6 +188,40 @@ function Game:returnToTitle() self.stack:push(self:makeTitleState()) end +Game.SKIN_FAST_FORWARD = 4 + +function Game:touchSkinHotkey(action, pressed) + if action == "fast_forward_hold" then + if pressed then + self.skinSpeedSaved = self.speedOverride + self.speedOverride = Game.SKIN_FAST_FORWARD + else + self.speedOverride = self.skinSpeedSaved + self.skinSpeedSaved = nil + end + elseif action == "fast_forward_toggle" then + if pressed then self:_cycleSpeed(1) end + elseif action == "soft_reset" then + if pressed then + Input:reset() + TouchControls:reset() + self:returnToTitle() + end + elseif action == "menu" then + if pressed then + local Screens = require("src.ui.Screens") + local top = self.stack and self.stack:top() + if top and not top.isTouchSkinMenu then + local state = Screens.build(self, "OptionsMenu") + if state then + state.isTouchSkinMenu = true + self.stack:push(state) + end + end + end + end +end + function Game:step(dt) -- Tool mods (autoplay, accessibility drivers, input visualizers) act on -- the same fixed-step boundary as a physical controller. Run them before @@ -1196,6 +1233,7 @@ function Game:restoreSave(loaded, recovered, opts) self:applyOptions(loaded.options) -- saves from before OT/ID stamping: backfill with the player's (after -- the scrub, so every mon the stamp loop sees is known) + SaveData.repairTradedOtIds(loaded) local stamp = require("src.battle.BattleState").stampOT for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end for _, box in ipairs(loaded.boxes or {}) do diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 7871cbdd..e1cfd40d 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -939,6 +939,8 @@ function Game2:load() self.data.gen2Maps = loadGenerated("data/generated/maps.lua") self.data.gen2Tilesets = loadGenerated("data/generated/tilesets.lua") self.data.gen2Roofs = loadGenerated("data/generated/roofs.lua") + -- engine/events/magnet_train.asm:165 DrawMagnetTrain + self.data.gen2Field = loadGenerated("data/generated/field.lua") self.data.gen2Marts = loadGenerated("data/generated/marts.lua") self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua") self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua") @@ -1965,6 +1967,7 @@ function Game2:applyOptions() haptics = options.haptics, }) require("src.core.VideoMode").applyOptions(options) + require("src.core.FrameCap").applyOptions(options) local GBCFX = require("src.render.GBCFX") if GBCFX.applyOptions(options) and self.save then -- applyOptions returns true when it had to clear an unsupported level. diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index b619ebaa..156715f2 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -6,11 +6,14 @@ local HostShell = {} -- spawn tries to link against the libraries we're shipping instead of the -- system ones. We want to unset the var so that any system tools can find -- their proper libraries. Only needed when running in an AppImage. +-- LD_PRELOAD is Steam's overlay (#1470): its 32-bit half cannot load into a +-- 64-bit child, so ld.so prints an error into that child's output. function HostShell.envPrefix() - if os.getenv("APPIMAGE") then - return "env -u LD_LIBRARY_PATH " - end - return "" + local unset = "" + if os.getenv("APPIMAGE") then unset = unset .. "-u LD_LIBRARY_PATH " end + if os.getenv("LD_PRELOAD") then unset = unset .. "-u LD_PRELOAD " end + if unset == "" then return "" end + return "env " .. unset end -- Windows: every host tool we shell out to (curl for the update and mod-index @@ -222,11 +225,20 @@ end local HTTP_MARK = "\n__gen1recomp_http__" local HTTP_MARK_FMT = "\\n__gen1recomp_http__%{http_code}" +local function stripLoaderNoise(out) + while out:find("^ERROR: ld%.so:") do + local nl = out:find("\n", 1, true) + if not nl then return "" end + out = out:sub(nl + 1) + end + return out +end + -- Split a curl pipe's output into (body, status, noise). `status` is nil -- when curl never got far enough to have one (DNS failure, no route, a -- timeout), in which case `noise` carries curl's own complaint. local function splitCurlOutput(out) - out = tostring(out or "") + out = stripLoaderNoise(tostring(out or "")) local at = nil local from = 1 while true do diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 6114385f..a63db2a4 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -666,11 +666,10 @@ function SaveData.setModEnabled(options, id, enabled, version) options.modsByVersion = options.modsByVersion or {} local bucket = options.modsByVersion[version] or {} options.modsByVersion[version] = bucket - -- no shared flag reads as enabled, the same default the loader applies to a - -- missing entry, so a fresh install never fills the overlay with agreement + -- with no shared flag the default is the caller's (experimental mods read as + -- disabled), so the answer is stored outright rather than judged against one local shared = options.mods and options.mods[id] - if type(shared) ~= "boolean" then shared = true end - if shared == enabled then + if type(shared) == "boolean" and shared == enabled then bucket[id] = nil else bucket[id] = enabled @@ -1924,6 +1923,26 @@ function SaveData.applyPostGameHome(save, boot) return heal end +-- 0.1.82-0.1.9x loads stamped the player's own id onto traded mons and saved +-- it, which reads back as home-caught. A caught mon can never carry +-- traded=true, so clearing that pair is safe on every load. #1461 +function SaveData.repairTradedOtIds(save) + local playerId = save and save.player and save.player.id + if playerId == nil then return 0 end + local fixed = 0 + local function scrub(mon) + if mon and mon.traded == true and mon.otId == playerId then + mon.otId = nil + fixed = fixed + 1 + end + end + for _, mon in ipairs(save.party or {}) do scrub(mon) end + for _, box in ipairs(save.boxes or {}) do + for _, mon in ipairs(box) do scrub(mon) end + end + return fixed +end + -- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits, -- with lastOutdoor on Indigo. One-shot rescue on CONTINUE. function SaveData.needsPostGameRescue(save) diff --git a/src/core/SkinZip.lua b/src/core/SkinZip.lua new file mode 100644 index 00000000..f363b112 --- /dev/null +++ b/src/core/SkinZip.lua @@ -0,0 +1,92 @@ +local SkinZip = {} + +local bxor +do + local ok, bit = pcall(require, "bit") + if ok and bit and bit.bxor then + bxor = function(a, b) return bit.bxor(a, b) % 0x100000000 end + else + local byteXor = {} + local function xor8(a, b) + local key = a * 256 + b + local memo = byteXor[key] + if memo then return memo end + local r, bitv = 0, 1 + local x, y = a, b + for _ = 1, 8 do + if (x % 2) ~= (y % 2) then r = r + bitv end + x, y, bitv = math.floor(x / 2), math.floor(y / 2), bitv * 2 + end + byteXor[key] = r + return r + end + bxor = function(a, b) + local r, mul = 0, 1 + for _ = 1, 4 do + r = r + xor8(a % 256, b % 256) * mul + a, b, mul = math.floor(a / 256), math.floor(b / 256), mul * 256 + end + return r + end + end +end + +local crcTable +local function crc32(s) + if not crcTable then + crcTable = {} + for i = 0, 255 do + local c = i + for _ = 1, 8 do + if c % 2 == 1 then + c = bxor(math.floor(c / 2), 0xEDB88320) + else + c = math.floor(c / 2) + end + end + crcTable[i] = c + end + end + local crc = 0xFFFFFFFF + for i = 1, #s do + crc = bxor(crcTable[bxor(crc, s:byte(i)) % 256], math.floor(crc / 256)) + end + return bxor(crc, 0xFFFFFFFF) % 0x100000000 +end + +local function le16(n) + n = math.floor(n) % 0x10000 + return string.char(n % 256, math.floor(n / 256) % 256) +end + +local function le32(n) + n = math.floor(n) % 0x100000000 + return string.char(n % 256, math.floor(n / 256) % 256, + math.floor(n / 65536) % 256, math.floor(n / 16777216) % 256) +end + +function SkinZip.encode(entries) + local out, central, offset = {}, {}, 0 + for _, entry in ipairs(entries) do + local name, data = entry.name, entry.data or "" + local crc, size = crc32(data), #data + local local_ = "PK\3\4" .. le16(20) .. le16(0) .. le16(0) + .. le16(0) .. le16(0) + .. le32(crc) .. le32(size) .. le32(size) + .. le16(#name) .. le16(0) .. name + out[#out + 1] = local_ + out[#out + 1] = data + central[#central + 1] = "PK\1\2" .. le16(20) .. le16(20) .. le16(0) .. le16(0) + .. le16(0) .. le16(0) + .. le32(crc) .. le32(size) .. le32(size) + .. le16(#name) .. le16(0) .. le16(0) .. le16(0) .. le16(0) + .. le32(0) .. le32(offset) .. name + offset = offset + #local_ + #data + end + local dir = table.concat(central) + return table.concat(out) .. dir + .. "PK\5\6" .. le16(0) .. le16(0) .. le16(#entries) .. le16(#entries) + .. le32(#dir) .. le32(offset) .. le16(0) +end + +return SkinZip diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 7d879c41..d54a04c0 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -39,6 +39,7 @@ local Input = require("src.core.Input") local SafeArea = require("src.core.SafeArea") +local TouchSkin = require("src.core.TouchSkin") local TouchControls = {} @@ -189,6 +190,7 @@ function TouchControls.normalizeConfig(tc) -- no caller ever has to nil-check a bucket's scale if type(tc) ~= "table" then tc = {} end if tc.enabled == false then out.enabled = false end + if type(tc.skin) == "string" and tc.skin ~= "" then out.skin = tc.skin end local saved = type(tc.layouts) == "table" and tc.layouts or nil for _, o in ipairs(ORIENTATIONS) do local b = saved and saved[o] @@ -241,6 +243,7 @@ end function TouchControls:init() self.active = wantsOverlay() + TouchSkin.setOverlayLive(self.active) self.enabled = true -- vibration level for presses (#806); applyOptions overwrites it from -- options.haptics, this is the value a harness that never applies options @@ -261,6 +264,10 @@ function TouchControls:init() self.held = {} self.dpadTouch = nil self.layoutW, self.layoutH = nil, nil + self.skinId = nil + self.skinError = nil + self.hotkeysHeld = {} + TouchSkin.setActive(nil) self.img = nil -- Images load whenever the platform wants the overlay OR the launcher -- editor forces a preview (desktop testing of the editor). @@ -285,6 +292,8 @@ function TouchControls:applyOptions(opts) -- haptics is a plain top-level option, not part of the layout config the -- launcher editor round-trips through config() (#806) self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics) + TouchSkin.setOverlayLive(self.active) + self:selectSkin(cfg.skin) self.layouts = cfg.layouts self.layoutW, self.layoutH = nil, nil self.layoutOx, self.layoutOy = nil, nil @@ -300,7 +309,7 @@ end -- Snapshot for the editor's save path: enabled plus both orientation -- buckets, matching what options.lua stores (#633). function TouchControls:config() - local out = { enabled = self.enabled ~= false, layouts = {} } + local out = { enabled = self.enabled ~= false, skin = self.skinId, layouts = {} } for _, o in ipairs(ORIENTATIONS) do local b = self.layouts and self.layouts[o] or nil out.layouts[o] = { @@ -323,11 +332,56 @@ function TouchControls:setPreview(on) end function TouchControls:visible() - if self.preview then return self.img ~= nil end - return self.active and self.enabled ~= false and self.img ~= nil - and not self.controllerHidden + local art = TouchSkin.active ~= nil or self.img ~= nil + if self.preview then return art end + if self.enabled == false or not art then return false end + if TouchSkin.active and TouchSkin.decorativeOnly() then return true end + return self.active and not self.controllerHidden end +local function surfaceRect() + local r = TouchSkin.surfaceRect + if r then return r.w, r.h, r.x, r.y end + if love and love.graphics and love.graphics.getDimensions then + local w, h = love.graphics.getDimensions() + return w, h, 0, 0 + end + return 0, 0, 0, 0 +end + +TouchControls.surfaceRect = surfaceRect + +function TouchControls:selectSkin(id) + if id == self.skinId and TouchSkin.active then return TouchSkin.active end + self:reset() + self.skinError = nil + if not id or id == "" then + self.skinId = nil + TouchSkin.setActive(nil) + return nil + end + local skin, err = TouchSkin.select(id) + if not skin then + self.skinId = nil + self.skinError = err + TouchSkin.setActive(nil) + return nil, err + end + self.skinId = id + return skin +end + +function TouchControls:skin() + if not self:visible() then return nil end + return TouchSkin.active +end + +function TouchControls:setHotkeyHandler(fn) + self.hotkeyHandler = type(fn) == "function" and fn or nil +end + +local skinHitSet, applySkinSet + -- Keep a control fully inside the usable rect [x0,y0]..[x1,y1]. local function clampZone(zone, x0, y0, x1, y1) local half = zone.w * 0.5 @@ -451,6 +505,15 @@ end -- Which control (if any) contains (x, y). Prefer face buttons over the -- d-pad when they overlap, matching touchpressed's order. function TouchControls:hitTest(x, y) + local page = TouchSkin.page() + if page then + local ww, wh, ox, oy = surfaceRect() + for i = #page.controls, 1, -1 do + local ctl = page.controls[i] + if TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then return ctl.spec, ctl end + end + return nil + end local L = self:layout() for _, btn in ipairs(BUTTONS) do if inCircle(L[btn], x, y, SLOP[btn]) then return btn end @@ -505,6 +568,86 @@ local function setDpad(self, touch, dir) if dir then pressBtn(self, dir) end end +local function pressKey(key, down) + local fn = down and love.keypressed or love.keyreleased + if type(fn) == "function" then pcall(fn, key, key, false) end +end + +local function fireHotkey(self, action, pressed, ctl) + if action == "overlay_next" then + if pressed then TouchSkin.nextPage(ctl and ctl.nextTarget) end + return pressed + end + if action == "overlay_prev" then + if pressed then TouchSkin.setPage(TouchSkin.pageIndex - 1) end + return pressed + end + self.hotkeysHeld = self.hotkeysHeld or {} + local n = (self.hotkeysHeld[action] or 0) + (pressed and 1 or -1) + if n < 0 then n = 0 end + self.hotkeysHeld[action] = n > 0 and n or nil + local edge = (pressed and n == 1) or (not pressed and n == 0) + if edge and self.hotkeyHandler then + pcall(self.hotkeyHandler, action, pressed) + end + return false +end + +local function enterControl(self, ctl) + local buzzed = false + for _, btn in ipairs(ctl.buttons) do + if not self.held[btn] then buzzed = true end + pressBtn(self, btn) + end + for _, key in ipairs(ctl.keys) do pressKey(key, true) end + local switched = false + for _, action in ipairs(ctl.hotkeys) do + if fireHotkey(self, action, true, ctl) then switched = true end + end + if not buzzed and (ctl.keys[1] or ctl.hotkeys[1]) then + TouchControls.buzz(self.haptics) + end + return switched +end + +local function exitControl(self, ctl) + for _, btn in ipairs(ctl.buttons) do releaseBtn(self, btn) end + for _, key in ipairs(ctl.keys) do pressKey(key, false) end + for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end +end + +function skinHitSet(self, x, y) + local page = TouchSkin.page() + if not page then return nil end + local ww, wh, ox, oy = surfaceRect() + local set = nil + for _, ctl in ipairs(page.controls) do + if not ctl.decorative and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then + set = set or {} + set[ctl] = true + end + end + return set +end + +function applySkinSet(self, touch, set) + local prev = touch.set or {} + local switched = false + for ctl in pairs(prev) do + if not (set and set[ctl]) then exitControl(self, ctl) end + end + for ctl in pairs(set or {}) do + if not prev[ctl] then + if enterControl(self, ctl) then switched = true end + end + end + touch.set = set + if switched then + for ctl in pairs(touch.set or {}) do exitControl(self, ctl) end + touch.set = nil + end +end + -- Returns true when this touch was captured by a virtual control -- the -- pad's first refusal on the gameplay pointer seam (#807). Capture is -- decided here, at press, and rides self.touches[id] for the touch's @@ -513,13 +656,22 @@ end function TouchControls:touchpressed(id, x, y) -- preview mode is layout-edit only: never press GB buttons if self.preview then return end - if not (self.active and self.enabled ~= false and self.img) then return end + if not (self.active and self.enabled ~= false + and (TouchSkin.active or self.img)) then return end -- a controller hid the overlay; the first touch only brings it back -- (uncaptured: it began on no control, so mods may still see it) if self.controllerHidden then self.controllerHidden = false return end + if TouchSkin.active then + local set = skinHitSet(self, x, y) + if not set then return end + local touch = { control = "skin" } + self.touches[id] = touch + applySkinSet(self, touch, set) + return true + end local L = self:layout() for _, btn in ipairs(BUTTONS) do if inCircle(L[btn], x, y, SLOP[btn]) then @@ -544,9 +696,14 @@ end function TouchControls:touchmoved(id, x, y) if self.preview then return end local touch = self.touches[id] + if not touch then return end + if touch.control == "skin" then + applySkinSet(self, touch, skinHitSet(self, x, y)) + return + end -- only the d-pad tracks movement (slide between directions without -- lifting); buttons hold until release wherever the finger wanders - if not touch or touch.control ~= "dpad" then return end + if touch.control ~= "dpad" then return end setDpad(self, touch, dpadDir(self:layout().dpad, x, y)) end @@ -555,7 +712,9 @@ function TouchControls:touchreleased(id, x, y) local touch = self.touches[id] if not touch then return end self.touches[id] = nil - if touch.control == "dpad" then + if touch.control == "skin" then + applySkinSet(self, touch, nil) + elseif touch.control == "dpad" then setDpad(self, touch, nil) self.dpadTouch = nil else @@ -568,6 +727,15 @@ end -- touchreleased and would strand its button held forever. Called from -- Game alongside Input:reset() on focus/visibility loss. function TouchControls:reset() + for _, touch in pairs(self.touches or {}) do + if touch.control == "skin" and touch.set then + for ctl in pairs(touch.set) do exitControl(self, ctl) end + end + end + for action in pairs(self.hotkeysHeld or {}) do + if self.hotkeyHandler then pcall(self.hotkeyHandler, action, false) end + end + self.hotkeysHeld = {} for btn in pairs(self.held or {}) do Input:overlayReleased(btn) end @@ -608,12 +776,56 @@ local function drawIcon(img, zone, pressed, alphaMul) zone.cy - img:getHeight() * scale / 2, 0, scale, scale) end +local function drawStretched(img, x, y, w, h, alpha) + if not img or alpha <= 0 then return end + local iw, ih = img:getWidth(), img:getHeight() + if iw <= 0 or ih <= 0 then return end + love.graphics.setColor(1, 1, 1, math.min(1, alpha)) + love.graphics.draw(img, x, y, 0, w / iw, h / ih) +end + +function TouchControls:drawSkin(alphaMul) + local page = TouchSkin.page() + if not page then return false end + local ww, wh, sox, soy = surfaceRect() + local bx, by, bw, bh = TouchSkin.pageBox(page, ww, wh, sox, soy) + local opacity = (self.skinOpacity or 1) * alphaMul + + love.graphics.push("all") + love.graphics.origin() + drawStretched(page.image, bx, by, bw, bh, opacity) + + local pressed = {} + for _, touch in pairs(self.touches or {}) do + for ctl in pairs(touch.set or {}) do pressed[ctl] = true end + end + + for _, ctl in ipairs(page.controls) do + local down = pressed[ctl] == true + local img = (down and ctl.pressedImage) or ctl.image + if img then + local cx, cy, halfW, halfH = + TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy) + local alpha = opacity + if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end + drawStretched(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha) + end + end + + love.graphics.pop() + return true +end + -- OS-window space, called after GameViewport.finish so the overlay rides on -- top of the game, companion composition and post-processing without being -- captured or scaled with any game viewport. Also used by the launcher layout -- editor under preview mode. function TouchControls:draw() if not self:visible() then return end + if TouchSkin.active then + local mul = (self.preview and self.enabled == false) and 0.45 or 1 + if self:drawSkin(mul) then return end + end local L = self:layout() -- when the player disabled the overlay but the editor is previewing, -- draw dimmed so the layout is still editable diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua new file mode 100644 index 00000000..db1db502 --- /dev/null +++ b/src/core/TouchSkin.lua @@ -0,0 +1,842 @@ +local TouchSkin = {} + +TouchSkin.BUNDLED_ROOT = "assets/skins" +TouchSkin.USER_ROOT = "skins" + +TouchSkin.GB_BUTTONS = { + a = "a", b = "b", start = "start", select = "select", + up = "up", down = "down", left = "left", right = "right", +} + +TouchSkin.HOTKEYS = { + overlay_next = "overlay_next", + overlay_previous = "overlay_prev", + menu_toggle = "menu", + reset = "soft_reset", + hold_fast_forward = "fast_forward_hold", + fast_forward = "fast_forward_hold", + toggle_fast_forward = "fast_forward_toggle", + screenshot = "screenshot", + pause_toggle = "pause", + exit_emulator = "quit", +} + +local DEFAULT_ASPECT = 16 / 9 +local PORTRAIT_ASPECT = 9 / 16 + +local function trim(s) + return (tostring(s or ""):match("^%s*(.-)%s*$")) +end + +local function unquote(s) + s = trim(s) + local inner = s:match('^"(.*)"$') + return inner or s +end + +local function toBool(v) + v = trim(v):lower() + return v == "true" or v == "1" +end + +local function tokens(s) + local out = {} + for tok in tostring(s or ""):gmatch("[^,%s]+") do out[#out + 1] = tok end + return out +end + +local function num(v, fallback) + local n = tonumber(trim(v)) + if not n or n ~= n then return fallback end + return n +end + +function TouchSkin.parseConfig(text) + local kv = {} + for line in tostring(text or ""):gmatch("[^\r\n]+") do + if not line:match("^%s*#") then + local key, value = line:match("^%s*([%w_]+)%s*=%s*(.-)%s*$") + if key then kv[key] = unquote(value) end + end + end + return kv +end + +local function parseBinds(spec) + local buttons, hotkeys, keys, decorative = {}, {}, {}, true + for raw in tostring(spec or ""):gmatch("[^|]+") do + local name = trim(raw):lower() + local key = name:match("^key:(.+)$") or name:match("^retrok_(.+)$") + if name == "nul" or name == "" then + -- decoration + elseif key then + keys[#keys + 1] = key + decorative = false + elseif TouchSkin.GB_BUTTONS[name] then + buttons[#buttons + 1] = TouchSkin.GB_BUTTONS[name] + decorative = false + elseif TouchSkin.HOTKEYS[name] then + hotkeys[#hotkeys + 1] = TouchSkin.HOTKEYS[name] + decorative = false + end + end + return buttons, hotkeys, keys, decorative +end + +local function parseDesc(kv, prefix, page) + local spec = kv[prefix] + if not spec then return nil end + local t = tokens(spec) + if #t < 6 then return nil end + + local shape = trim(t[4]):lower() + if shape ~= "radial" and shape ~= "rect" then shape = "rect" end + + local buttons, hotkeys, keys, decorative = parseBinds(t[1]) + local reachX = num(kv[prefix .. "_reach_x"], 1) + local reachY = num(kv[prefix .. "_reach_y"], 1) + + local ctl = { + spec = t[1], + buttons = buttons, + hotkeys = hotkeys, + keys = keys, + decorative = decorative, + x = num(t[2], 0.5), + y = num(t[3], 0.5), + shape = shape, + rangeX = math.abs(num(t[5], 0.05)), + rangeY = math.abs(num(t[6], 0.05)), + rangeMod = num(kv[prefix .. "_range_mod"], page.rangeMod), + alphaMod = num(kv[prefix .. "_alpha_mod"], page.alphaMod), + reachUp = num(kv[prefix .. "_reach_up"], reachY), + reachDown = num(kv[prefix .. "_reach_down"], reachY), + reachLeft = num(kv[prefix .. "_reach_left"], reachX), + reachRight = num(kv[prefix .. "_reach_right"], reachX), + imagePath = kv[prefix .. "_overlay"], + pressedImagePath = kv[prefix .. "_overlay_pressed"], + nextTarget = kv[prefix .. "_next_target"], + } + if ctl.imagePath == "" then ctl.imagePath = nil end + if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end + return ctl +end + +function TouchSkin.parse(text) + local kv = TouchSkin.parseConfig(text) + local count = math.floor(num(kv.overlays, 0)) + if count <= 0 then return nil, "no overlays" end + + local pages = {} + for i = 0, count - 1 do + local p = "overlay" .. i + local page = { + index = i + 1, + name = kv[p .. "_name"] or ("overlay" .. i), + imagePath = kv[p .. "_overlay"], + fullScreen = toBool(kv[p .. "_full_screen"]), + normalized = toBool(kv[p .. "_normalized"]), + rangeMod = num(kv[p .. "_range_mod"], 1), + alphaMod = num(kv[p .. "_alpha_mod"], 1), + aspect = num(kv[p .. "_aspect_ratio"], nil), + controls = {}, + } + if page.imagePath == "" then page.imagePath = nil end + if not page.aspect or page.aspect <= 0 then + page.aspect = page.name:lower():find("portrait", 1, true) + and PORTRAIT_ASPECT or DEFAULT_ASPECT + end + + local rect = tokens(kv[p .. "_rect"]) + page.rect = { x = 0, y = 0, w = 1, h = 1 } + if #rect >= 4 then + page.rect = { x = num(rect[1], 0), y = num(rect[2], 0), + w = num(rect[3], 1), h = num(rect[4], 1) } + end + + local vp = tokens(kv[p .. "_viewport"]) + if #vp >= 4 then + page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0), + w = num(vp[3], 1), h = num(vp[4], 1) } + page.viewportFill = toBool(kv[p .. "_viewport_fill"]) + page.viewportExpand = toBool(kv[p .. "_viewport_expand"]) + end + + local descs = math.floor(num(kv[p .. "_descs"], 0)) + for d = 0, descs - 1 do + local ctl = parseDesc(kv, p .. "_desc" .. d, page) + if ctl then page.controls[#page.controls + 1] = ctl end + end + pages[#pages + 1] = page + end + + return { pages = pages } +end + +local function readFile(path) + if love and love.filesystem and love.filesystem.read then + local ok, data = pcall(love.filesystem.read, path) + if ok and data then return data end + end + local handle = io.open(path, "rb") + if not handle then return nil end + local data = handle:read("*a") + handle:close() + return data +end + +local function listDir(path) + if love and love.filesystem and love.filesystem.getDirectoryItems then + local ok, items = pcall(love.filesystem.getDirectoryItems, path) + if ok and items then return items end + end + return {} +end + +local function isDir(path) + if love and love.filesystem and love.filesystem.getInfo then + local ok, info = pcall(love.filesystem.getInfo, path) + if ok and info then return info.type == "directory" end + end + return false +end + +local function joinPath(root, rel) + rel = tostring(rel or ""):gsub("^%./", ""):gsub("\\", "/") + if rel:sub(1, 1) == "/" then return rel:sub(2) end + return root .. "/" .. rel +end + +TouchSkin.NATIVE_NAME = "skin.lua" + +local function findConfig(root) + if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then + return root .. "/" .. TouchSkin.NATIVE_NAME, "native" + end + local named = { "overlay.cfg", "skin.cfg", "layout.cfg" } + for _, name in ipairs(named) do + if readFile(root .. "/" .. name) then return root .. "/" .. name, "retroarch" end + end + local items = listDir(root) + table.sort(items) + for _, name in ipairs(items) do + if name:match("%.cfg$") then return root .. "/" .. name, "retroarch" end + end + return nil +end + +local function loadDataChunk(text, name) + if loadstring then + local chunk, err = loadstring(text, name) + if not chunk then return nil, err end + return setfenv(chunk, {}) + end + return load(text, name, "t", {}) +end + +function TouchSkin.parseNative(text) + local chunk, err = loadDataChunk(tostring(text or ""), "skin") + if not chunk then return nil, "skin.lua does not parse: " .. tostring(err) end + local ok, data = pcall(chunk) + if not ok or type(data) ~= "table" then return nil, "skin.lua returned no table" end + if type(data.pages) ~= "table" or not data.pages[1] then + return nil, "skin.lua has no pages" + end + + local pages = {} + for i, raw in ipairs(data.pages) do + if type(raw) ~= "table" then return nil, "page " .. i .. " is not a table" end + local page = { + index = i, + name = tostring(raw.name or ("page" .. i)), + imagePath = raw.image, + fullScreen = raw.fullScreen ~= false, + normalized = true, + rangeMod = num(raw.rangeMod, 1), + alphaMod = num(raw.alphaMod, 1), + aspect = num(raw.aspect, DEFAULT_ASPECT), + rect = { x = 0, y = 0, w = 1, h = 1 }, + controls = {}, + } + if type(raw.rect) == "table" then + page.rect = { x = num(raw.rect.x, 0), y = num(raw.rect.y, 0), + w = num(raw.rect.w, 1), h = num(raw.rect.h, 1) } + end + if type(raw.viewport) == "table" then + page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0), + w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) } + page.viewportFill = raw.viewport.fill == true + page.viewportExpand = raw.viewport.expand == true + end + for _, c in ipairs(raw.controls or {}) do + local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul") + page.controls[#page.controls + 1] = { + spec = tostring(c.bind or "nul"), + buttons = buttons, hotkeys = hotkeys, keys = keys, + decorative = decorative, + x = num(c.x, 0.5), y = num(c.y, 0.5), + shape = c.shape == "radial" and "radial" or "rect", + rangeX = math.abs(num(c.w, 0.1)) * 0.5, + rangeY = math.abs(num(c.h, 0.1)) * 0.5, + rangeMod = num(c.rangeMod, page.rangeMod), + alphaMod = num(c.alphaMod, page.alphaMod), + reachUp = num(c.reachUp, 1), reachDown = num(c.reachDown, 1), + reachLeft = num(c.reachLeft, 1), reachRight = num(c.reachRight, 1), + imagePath = c.image, + pressedImagePath = c.imagePressed, + nextTarget = c.nextTarget, + } + end + pages[#pages + 1] = page + end + return { pages = pages, name = data.name, author = data.author, + notes = data.notes, format = "native" } +end + +function TouchSkin.toNative(skin) + local out = { + name = skin.name or skin.id, + author = skin.author, + notes = skin.notes, + format = 1, + pages = {}, + } + for _, page in ipairs(skin.pages or {}) do + local p = { + name = page.name, + image = page.imagePath, + fullScreen = page.fullScreen ~= false, + rangeMod = page.rangeMod, + alphaMod = page.alphaMod, + aspect = page.aspect, + controls = {}, + } + if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0 + or page.rect.w ~= 1 or page.rect.h ~= 1) then + p.rect = { x = page.rect.x, y = page.rect.y, w = page.rect.w, h = page.rect.h } + end + if page.viewport then + p.viewport = { + x = page.viewport.x, y = page.viewport.y, + w = page.viewport.w, h = page.viewport.h, + fill = page.viewportFill or nil, + expand = page.viewportExpand or nil, + } + end + for _, ctl in ipairs(page.controls or {}) do + p.controls[#p.controls + 1] = { + bind = ctl.spec, + x = ctl.x, y = ctl.y, + w = ctl.rangeX * 2, h = ctl.rangeY * 2, + shape = ctl.shape, + rangeMod = ctl.rangeMod ~= p.rangeMod and ctl.rangeMod or nil, + alphaMod = ctl.alphaMod ~= p.alphaMod and ctl.alphaMod or nil, + reachUp = ctl.reachUp ~= 1 and ctl.reachUp or nil, + reachDown = ctl.reachDown ~= 1 and ctl.reachDown or nil, + reachLeft = ctl.reachLeft ~= 1 and ctl.reachLeft or nil, + reachRight = ctl.reachRight ~= 1 and ctl.reachRight or nil, + image = ctl.imagePath, + imagePressed = ctl.pressedImagePath, + nextTarget = ctl.nextTarget, + } + end + out.pages[#out.pages + 1] = p + end + return out +end + +function TouchSkin.serialize(skin) + return require("src.import.LuaWriter").encode(TouchSkin.toNative(skin)) +end + +local imageCache = setmetatable({}, { __mode = "v" }) + +local function loadImage(path) + if not (love and love.graphics and love.graphics.newImage) then return nil end + local cached = imageCache[path] + if cached then return cached end + local ok, img = pcall(love.graphics.newImage, path) + if not ok or not img then return nil end + if img.setFilter then img:setFilter("linear", "linear") end + imageCache[path] = img + return img +end + +function TouchSkin.load(root, id) + local cfgPath, format = findConfig(root) + if not cfgPath then return nil, "no skin.lua or .cfg in " .. root end + local text = readFile(cfgPath) + if not text then return nil, "unreadable " .. cfgPath end + local skin, err + if format == "native" then + skin, err = TouchSkin.parseNative(text) + else + skin, err = TouchSkin.parse(text) + end + if not skin then return nil, err end + + skin.id = id or root:match("([^/]+)$") or root + skin.root = root + skin.configPath = cfgPath + skin.format = format + skin.name = skin.name or skin.pages[1] and skin.pages[1].name or skin.id + + for _, page in ipairs(skin.pages) do + if page.imagePath then + page.image = loadImage(joinPath(root, page.imagePath)) + end + for _, ctl in ipairs(page.controls) do + if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end + if ctl.pressedImagePath then + ctl.pressedImage = loadImage(joinPath(root, ctl.pressedImagePath)) + end + end + end + return skin +end + +local function mountZip(archive, point) + if not (love and love.filesystem and love.filesystem.mount) then return false end + local ok, mounted = pcall(love.filesystem.mount, archive, point) + return ok and mounted == true +end + +function TouchSkin.list() + local out, seen = {}, {} + local function scan(root, source) + for _, name in ipairs(listDir(root)) do + local id = name:gsub("%.zip$", "") + if not seen[id] then + local path = root .. "/" .. name + if name:match("%.zip$") then + local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id + if mountZip(path, point) and findConfig(point) then + seen[id] = true + out[#out + 1] = { id = id, root = point, source = source, archive = path } + end + elseif isDir(path) and findConfig(path) then + seen[id] = true + out[#out + 1] = { id = id, root = path, source = source } + end + end + end + end + if love and love.filesystem and love.filesystem.createDirectory then + pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT) + end + scan(TouchSkin.USER_ROOT, "user") + scan(TouchSkin.BUNDLED_ROOT, "bundled") + table.sort(out, function(a, b) return a.id < b.id end) + return out +end + +-- Drop a .zip into /skins and report the id it will list under. +function TouchSkin.installArchive(name, data) + if not data or data == "" then return nil, "empty archive" end + if not (love and love.filesystem and love.filesystem.write) then + return nil, "no writable filesystem" + end + name = tostring(name or ""):match("([^/\\]+)$") or "" + name = name:gsub("[^%w%._%-]", "_") + if not name:lower():match("%.zip$") then return nil, "not a .zip" end + local id = name:gsub("%.[Zz][Ii][Pp]$", "") + if id == "" then return nil, "bad archive name" end + + pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT) + local dest = TouchSkin.USER_ROOT .. "/" .. name + local ok, err = love.filesystem.write(dest, data) + if not ok then return nil, tostring(err) end + + local entry = TouchSkin.find(id) + if not entry then + love.filesystem.remove(dest) + return nil, "no skin.lua or .cfg inside " .. name + end + return id +end + +function TouchSkin.find(id) + if not id or id == "" then return nil end + for _, entry in ipairs(TouchSkin.list()) do + if entry.id == id then return entry end + end + return nil +end + +function TouchSkin.assetPaths(skin) + local out, seen = {}, {} + local function add(rel) + if rel and rel ~= "" and not seen[rel] then + seen[rel] = true + out[#out + 1] = rel + end + end + for _, page in ipairs(skin.pages or {}) do + add(page.imagePath) + for _, ctl in ipairs(page.controls or {}) do + add(ctl.imagePath) + add(ctl.pressedImagePath) + end + end + return out +end + +function TouchSkin.export(skin, destPath) + if not skin then return nil, "no skin" end + local SkinZip = require("src.core.SkinZip") + local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } } + local missing = {} + for _, rel in ipairs(TouchSkin.assetPaths(skin)) do + local data = readFile(joinPath(skin.root, rel)) + if data then + entries[#entries + 1] = { name = rel, data = data } + else + missing[#missing + 1] = rel + end + end + if skin.configPath and skin.format == "retroarch" then + local cfg = readFile(skin.configPath) + if cfg then + entries[#entries + 1] = + { name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg } + end + end + local blob = SkinZip.encode(entries) + destPath = destPath or (TouchSkin.USER_ROOT .. "/" .. skin.id .. "-export.zip") + local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil + if not absolute and love and love.filesystem and love.filesystem.write then + local ok, err = love.filesystem.write(destPath, blob) + if not ok then return nil, tostring(err) end + else + local handle = io.open(destPath, "wb") + if not handle then return nil, "cannot write " .. destPath end + handle:write(blob) + handle:close() + end + return destPath, missing +end + +TouchSkin.BINDS = { + "nul", + "up", "down", "left", "right", + "a", "b", "start", "select", + "left|up", "right|up", "left|down", "right|down", + "hold_fast_forward", "toggle_fast_forward", "reset", "menu_toggle", + "overlay_next", +} + +function TouchSkin.describeBind(spec) + local buttons, hotkeys, keys, decorative = parseBinds(spec) + if decorative then return "decoration" end + local parts = {} + for _, b in ipairs(buttons) do parts[#parts + 1] = "GB " .. b:upper() end + for _, h in ipairs(hotkeys) do parts[#parts + 1] = h end + for _, k in ipairs(keys) do parts[#parts + 1] = "key " .. k end + return table.concat(parts, " + ") +end + +function TouchSkin.newControl(spec, x, y, w, h, shape) + local buttons, hotkeys, keys, decorative = parseBinds(spec) + return { + spec = spec, buttons = buttons, hotkeys = hotkeys, keys = keys, + decorative = decorative, + x = x, y = y, rangeX = w * 0.5, rangeY = h * 0.5, + shape = shape == "radial" and "radial" or "rect", + rangeMod = 1, alphaMod = 1, + reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1, + } +end + +function TouchSkin.setBind(ctl, spec) + ctl.spec = spec + ctl.buttons, ctl.hotkeys, ctl.keys, ctl.decorative = parseBinds(spec) + return ctl +end + +function TouchSkin.newSkin(id) + local page = { + index = 1, name = "main", fullScreen = true, normalized = true, + rangeMod = 1, alphaMod = 1, aspect = PORTRAIT_ASPECT, + rect = { x = 0, y = 0, w = 1, h = 1 }, + viewport = { x = 0, y = 0, w = 1, h = 0.5 }, + viewportFill = false, + controls = {}, + } + return { id = id or "new_skin", name = id or "new_skin", + root = TouchSkin.USER_ROOT .. "/" .. (id or "new_skin"), + format = "native", pages = { page } } +end + +local function copyTable(src) + if type(src) ~= "table" then return src end + local out = {} + for k, v in pairs(src) do out[k] = copyTable(v) end + return out +end + +function TouchSkin.clone(skin) + if not skin then return nil end + local out = { + id = skin.id, name = skin.name, root = skin.root, format = skin.format, + author = skin.author, notes = skin.notes, configPath = skin.configPath, + source = skin.source, pages = {}, + } + for i, page in ipairs(skin.pages or {}) do + local p = copyTable(page) + p.image = page.image + p.controls = {} + for j, ctl in ipairs(page.controls or {}) do + local c = copyTable(ctl) + c.image = ctl.image + c.pressedImage = ctl.pressedImage + p.controls[j] = c + end + out.pages[i] = p + end + return out +end + +function TouchSkin.resolveImage(root, rel) + if not rel or rel == "" then return nil end + return loadImage(joinPath(root, rel)) +end + +function TouchSkin.detectViewport(root, imagePath) + if not (love and love.image and love.image.newImageData) then return nil end + if not imagePath or imagePath == "" then return nil end + local ok, data = pcall(love.image.newImageData, joinPath(root, imagePath)) + if not ok or not data then return nil end + local w, h = data:getWidth(), data:getHeight() + if w < 2 or h < 2 then return nil end + + local function clear(x, y) + if x < 0 or y < 0 or x >= w or y >= h then return false end + local okp, _, _, _, a = pcall(data.getPixel, data, x, y) + return okp and a ~= nil and a < 0.05 + end + + local cx, cy = math.floor(w / 2), math.floor(h / 2) + if not clear(cx, cy) then return nil end + + local left, right = cx, cx + while left > 0 and clear(left - 1, cy) do left = left - 1 end + while right < w - 1 and clear(right + 1, cy) do right = right + 1 end + local top, bottom = cy, cy + while top > 0 and clear(cx, top - 1) do top = top - 1 end + while bottom < h - 1 and clear(cx, bottom + 1) do bottom = bottom + 1 end + + local rw, rh = right - left + 1, bottom - top + 1 + if rw < 8 or rh < 8 then return nil end + return { x = left / w, y = top / h, w = rw / w, h = rh / h }, rw, rh +end + +-- Bring outside art into the skin's own folder. A skin still living in +-- assets/ or a mounted zip is saved out to /skins/ first, because +-- that is the only root the engine can write to. +function TouchSkin.importImage(skin, name, data) + if not skin then return nil, "no skin" end + if not data or data == "" then return nil, "no image data" end + if not (love and love.filesystem and love.filesystem.write) then + return nil, "no writable filesystem" + end + name = tostring(name):gsub("[^%w%._%-]", "_") + if name == "" then return nil, "bad file name" end + + local dest = TouchSkin.USER_ROOT .. "/" .. skin.id + if skin.root ~= dest then + local saved, err = TouchSkin.saveTo(skin, skin.id) + if not saved then return nil, tostring(err) end + end + pcall(love.filesystem.createDirectory, dest .. "/img") + local rel = "img/" .. name + local ok, err = love.filesystem.write(dest .. "/" .. rel, data) + if not ok then return nil, tostring(err) end + return rel +end + +function TouchSkin.listImages(root) + local out = {} + local function scan(dir, prefix) + for _, name in ipairs(listDir(dir)) do + local path = dir .. "/" .. name + if name:lower():match("%.png$") or name:lower():match("%.jpg$") then + out[#out + 1] = prefix .. name + elseif isDir(path) and prefix == "" then + scan(path, name .. "/") + end + end + end + scan(root, "") + table.sort(out) + return out +end + +function TouchSkin.saveTo(skin, id) + id = id or skin.id + if not id or id == "" then return nil, "no skin id" end + if not (love and love.filesystem and love.filesystem.write) then + return nil, "no writable filesystem" + end + local dest = TouchSkin.USER_ROOT .. "/" .. id + pcall(love.filesystem.createDirectory, dest) + + local copied, failed = 0, {} + for _, rel in ipairs(TouchSkin.assetPaths(skin)) do + local target = dest .. "/" .. rel + local dir = target:match("^(.*)/[^/]+$") + if dir then pcall(love.filesystem.createDirectory, dir) end + if skin.root ~= dest then + local data = readFile(joinPath(skin.root, rel)) + if data then + if love.filesystem.write(target, data) then copied = copied + 1 end + else + failed[#failed + 1] = rel + end + end + end + + local ok, err = love.filesystem.write(dest .. "/" .. TouchSkin.NATIVE_NAME, + TouchSkin.serialize(skin)) + if not ok then return nil, tostring(err) end + skin.id, skin.root, skin.format = id, dest, "native" + return dest, failed, copied +end + +TouchSkin.active = nil +TouchSkin.pageIndex = 1 + +TouchSkin.surfaceRect = nil + +function TouchSkin.setSurface(x, y, w, h) + if not w or w <= 0 or not h or h <= 0 then + TouchSkin.surfaceRect = nil + else + TouchSkin.surfaceRect = { x = x, y = y, w = w, h = h } + end + return TouchSkin.surfaceRect +end + +function TouchSkin.setActive(skin) + TouchSkin.active = skin or nil + TouchSkin.pageIndex = 1 + return TouchSkin.active +end + +function TouchSkin.select(id) + if not id or id == "" then return TouchSkin.setActive(nil) end + local entry = TouchSkin.find(id) + if not entry then return nil, "no skin " .. tostring(id) end + local skin, err = TouchSkin.load(entry.root, entry.id) + if not skin then return nil, err end + skin.source = entry.source + return TouchSkin.setActive(skin) +end + +function TouchSkin.page() + local skin = TouchSkin.active + if not skin then return nil end + return skin.pages[TouchSkin.pageIndex] or skin.pages[1] +end + +function TouchSkin.setPage(target) + local skin = TouchSkin.active + if not skin then return nil end + if type(target) == "number" then + local n = #skin.pages + TouchSkin.pageIndex = ((math.floor(target) - 1) % n) + 1 + return TouchSkin.page() + end + for i, page in ipairs(skin.pages) do + if page.name == target then + TouchSkin.pageIndex = i + return page + end + end + return TouchSkin.page() +end + +function TouchSkin.nextPage(target) + local skin = TouchSkin.active + if not skin then return nil end + if target and target ~= "" then return TouchSkin.setPage(target) end + return TouchSkin.setPage(TouchSkin.pageIndex + 1) +end + +function TouchSkin.pageBox(page, w, h, ox, oy) + ox, oy = ox or 0, oy or 0 + if not page then return ox, oy, w, h end + local bx, by, bw, bh = ox, oy, w, h + if not page.fullScreen and h > 0 then + local displayAspect = w / h + if displayAspect > page.aspect then + bw = h * page.aspect + bx = ox + (w - bw) * 0.5 + else + bh = w / page.aspect + by = oy + (h - bh) * 0.5 + end + end + local r = page.rect + return bx + r.x * bw, by + r.y * bh, r.w * bw, r.h * bh +end + +function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy) + local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy) + local cx, cy = bx + ctl.x * bw, by + ctl.y * bh + local halfW, halfH = ctl.rangeX * bw, ctl.rangeY * bh + return cx, cy, halfW, halfH +end + +function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy) + local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy) + local left = halfW * ctl.reachLeft * ctl.rangeMod + local right = halfW * ctl.reachRight * ctl.rangeMod + local up = halfH * ctl.reachUp * ctl.rangeMod + local down = halfH * ctl.reachDown * ctl.rangeMod + local dx = px - cx + local dy = py - cy + local rx = dx < 0 and left or right + local ry = dy < 0 and up or down + if rx <= 0 or ry <= 0 then return false end + if ctl.shape == "radial" then + return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1 + end + return math.abs(dx) <= rx and math.abs(dy) <= ry +end + +TouchSkin.overlayLive = false + +function TouchSkin.setOverlayLive(on) + TouchSkin.overlayLive = on and true or false +end + +function TouchSkin.decorativeOnly() + local page = TouchSkin.page() + if not page then return false end + for _, ctl in ipairs(page.controls) do + if not ctl.decorative then return false end + end + return true +end + +function TouchSkin.drawable() + if not TouchSkin.active then return false end + return TouchSkin.overlayLive or TouchSkin.decorativeOnly() +end + +function TouchSkin.hasViewport() + local page = TouchSkin.page() + return page ~= nil and page.viewport ~= nil and TouchSkin.drawable() +end + +function TouchSkin.viewport(w, h, ox, oy) + local page = TouchSkin.page() + if not page or not page.viewport or not TouchSkin.drawable() then return nil end + local v = page.viewport + local x, y = (ox or 0) + v.x * w, (oy or 0) + v.y * h + local vw, vh = v.w * w, v.h * h + if vw <= 0 or vh <= 0 then return nil end + return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true +end + +return TouchSkin diff --git a/src/core/gen2/Boxes.lua b/src/core/gen2/Boxes.lua index 9cbaf746..7a87bd93 100644 --- a/src/core/gen2/Boxes.lua +++ b/src/core/gen2/Boxes.lua @@ -101,6 +101,14 @@ function Boxes.canDeposit(save, partyIndex, boxIndex) return true end +-- RestorePPOfDepositedPokemon (engine/pokemon/move_mon.asm:711-773): every +-- slot back to GetMaxPPOfMove, which already carries its own PP Up count. +function Boxes.restorePP(mon) + for _, move in ipairs((mon and mon.moves) or {}) do + if type(move) == "table" then move.pp = move.maxPp or move.pp end + end +end + function Boxes.deposit(save, partyIndex, boxIndex) local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex) if not ok then return false, reason end @@ -112,6 +120,9 @@ function Boxes.deposit(save, partyIndex, boxIndex) Mail.removeSlot(save, partyIndex) local box = Boxes.box(save, boxIndex) box[#box + 1] = mon + -- SendGetMonIntoFromBox's PC_DEPOSIT arm ends in RestorePPOfDepositedPokemon + -- (engine/pokemon/move_mon.asm:633-635, :696-700). + Boxes.restorePP(mon) return true, mon end @@ -129,6 +140,15 @@ function Boxes.withdraw(save, boxIndex, slot) local ok, reason = Boxes.canWithdraw(save, boxIndex, slot) if not ok then return false, reason end local mon = table.remove(Boxes.box(save, boxIndex), slot) + -- The `get mon into Party` arm alone heals: status cleared and MON_MAXHP + -- copied over MON_HP, an egg's staying 0 (move_mon.asm:666-693). + mon.status = nil + mon.statusTurns = nil + if mon.isEgg then + mon.hp = 0 + else + mon.hp = mon.maxHp or mon.hp + end save.party = save.party or {} save.party[#save.party + 1] = mon return true, mon @@ -142,6 +162,16 @@ function Boxes.release(save, boxIndex, slot) return true, table.remove(box, slot) end +-- RELEASE off the DEPOSIT screen, whose list is the party: it is +-- RemoveMonFromPartyOrBox's REMOVE_PARTY arm (bills_pc.asm:204-207). +function Boxes.releaseFromParty(save, slot) + local party = (save and save.party) or {} + if not party[slot] then return false, "There is no POKéMON there." end + local mon = table.remove(party, slot) + Mail.removeSlot(save, slot) + return true, mon +end + -- Move a boxed mon to another box (MOVE PKMN W/O MAIL's box-to-box case). function Boxes.move(save, fromBox, slot, toBox) if fromBox == toBox then return false, "It's already there." end diff --git a/src/core/gen2/NpcTrade.lua b/src/core/gen2/NpcTrade.lua index 5c8d97e9..65c7eb30 100644 --- a/src/core/gen2/NpcTrade.lua +++ b/src/core/gen2/NpcTrade.lua @@ -159,6 +159,13 @@ function NpcTrade.perform(data, save, row, index) -- that slot inheriting it (src/core/gen2/Mail.lua). Mail.removeSlot(save, index) party[#party + 1] = received + -- TryAddMonToParty's SetSeenAndCaughtMon (engine/pokemon/move_mon.asm:196), + -- which the `predef` at engine/events/npc_trade.asm:168 runs like any other. + save.pokedex = save.pokedex or {} + save.pokedex.seen = save.pokedex.seen or {} + save.pokedex.caught = save.pokedex.caught or {} + save.pokedex.seen[received.species] = true + save.pokedex.caught[received.species] = true return given, received end diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index cd270b09..04b3a637 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -273,6 +273,7 @@ Save.DEFAULT_OPTIONS = { -- means something different, hence the different name. color = "gbc", videoMode = "windowed", + fpsCap = 60, musicVol = 7, -- 0-7, like the GB's NR50 master volume sfxVol = 7, -- 0-7 musicFilter = 0, -- low-pass steps, 0 = off diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 4004a86e..b4f5d3d9 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -122,6 +122,17 @@ local function addTouchRows(rows, add, opts, hooks) end, } end + + if hooks and hooks.openSkinStudio then + rows[#rows + 1] = { + label = Strings("SKIN STUDIO"), + actionLabel = Strings("Open"), + action = function() + hooks.openSkinStudio() + return false + end, + } + end end local function coreRows(opts, hooks) @@ -627,6 +638,16 @@ local function gen2Rows(opts, hooks) end) end + local okCap, FrameCap = pcall(require, "src.core.FrameCap") + if okCap then + add(Strings("MAX FPS"), + function() return FrameCap.label(opts.fpsCap) end, + function(dir) + opts.fpsCap = FrameCap.cycle(opts.fpsCap, dir) + return true + end) + end + addTouchRows(rows, add, opts, hooks) return rows @@ -638,6 +659,7 @@ end -- writes options while a modal covers it, so the cached table stays true. -- `hooks` carries the host actions a row cannot perform itself: -- editTouchControls() -- hand the screen to the touch-overlay editor +-- openSkinStudio() -- hand the screen to the desktop skin studio -- -- `version` is the game the gear was opened on. It picks the row set, and -- for Gold it also picks WHICH table the rows edit: the `gold` block inside diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index fcb6165f..c08d15a5 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -776,6 +776,26 @@ end -- A hand-drawn X / check: the UI font has no guaranteed glyph for either, -- and the launcher ships no icon asset for them. +-- Skins tab glyph: a bezel with a screen cutout and two face buttons, drawn +-- rather than shipped as art so the tab needs no new asset. +local function drawSkinGlyph(x, y, w, h, hot) + local box = math.min(w, h) + local bx = x + (w - box) / 2 + local by = y + (h - box) / 2 + local pad = math.floor(box * 0.22) + local ow, oh = box - 2 * pad, box - 2 * pad + local ink = hot and PAL.inverse or PAL.ink + local a = 1 + Theme.strokeRounded(bx + pad, by + pad, ow, oh, ink, a, + math.max(1, math.floor(Kit.scale)), math.floor(oh * 0.22)) + local sw, sh = ow * 0.58, oh * 0.40 + Theme.fillRounded(bx + pad + ow * 0.10, by + pad + oh * 0.14, sw, sh, ink, a, + math.max(1, math.floor(sh * 0.2))) + local r = math.max(1, oh * 0.09) + Theme.fillRounded(bx + pad + ow * 0.60, by + pad + oh * 0.64, r * 2, r * 2, ink, a, r) + Theme.fillRounded(bx + pad + ow * 0.80, by + pad + oh * 0.50, r * 2, r * 2, ink, a, r) +end + local function drawCross(x, y, size, color) love.graphics.push("all") love.graphics.setColor(color) @@ -807,18 +827,46 @@ end -- frame. Their tab rows, opts tables and action closures are built once -- instead of 60 times a second -- only `active`, `image` and the queued -- action are written per frame. +-- The four cartridges used to be four tabs of their own. They are one +-- dropdown now: the tab row was seven controls wide and wrapped to two rows on +-- anything narrow, and only ever one game is being looked at. +local GAME_TABS = { + { id = "red", key = "tab-red", letter = "R", color = PAL.railRed, + label = "Red" }, + { id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue, + label = "Blue" }, + { id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold, + label = "Yellow" }, + { id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber, + label = "Gold" }, +} + local HEADER_TABS = { - { id = "red", key = "tab-red", letter = "R", color = PAL.railRed }, - { id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue }, - { id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold }, - { id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber }, { id = "mods", key = "tab-mods" }, { id = "find", key = "tab-find" }, + { id = "skins", key = "tab-skins", glyph = true }, } for _, t in ipairs(HEADER_TABS) do t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter } + if t.glyph then t.opts.drawFn = drawSkinGlyph end end +-- Which cartridge the dropdown is showing: the open game tab, else the last +-- one visited, else Red. Kept as a function so the mods/find/skins panels +-- still answer "for which game" without a game tab being open. +local function currentGame(imp) + for _, g in ipairs(GAME_TABS) do + if imp.tab == g.id then return g end + end + for _, g in ipairs(GAME_TABS) do + if imp.modScope == g.id then return g end + end + return GAME_TABS[1] +end + +LauncherView.GAME_TABS = GAME_TABS +LauncherView.currentGame = currentGame + local QUIT_INK_HOT = { 0, 0, 0, 1 } local QUIT_INK_REST = { 1, 1, 1, 0.85 } @@ -837,11 +885,27 @@ local function headerChrome(imp) hot and QUIT_INK_HOT or QUIT_INK_REST) end }, tab = {}, + game = { face = "tab", font = "tab", + action = function() + local g = currentGame(imp) + if imp.tab == g.id then + imp._gamePopup = true + else + imp:_switchTab(g.id) + end + end }, } for _, t in ipairs(HEADER_TABS) do local id = t.id c.tab[id] = function() imp:_switchTab(id) end end + for _, g in ipairs(GAME_TABS) do + local id = g.id + c.tab[id] = function() + imp._gamePopup = nil + imp:_switchTab(id) + end + end imp._headerChrome = c return c end @@ -927,7 +991,10 @@ local function buildHeader(imp, m) -- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not -- collide. local tabs = HEADER_TABS - tabs[5].icon, tabs[6].icon = imp._modsIcon, imp._findIcon + for _, t in ipairs(tabs) do + if t.id == "mods" then t.icon = imp._modsIcon end + if t.id == "find" then t.icon = imp._findIcon end + end local tabH = m.chip local tx = m.x + m.pad local ty = y + math.floor(6 * m.s) @@ -935,6 +1002,40 @@ local function buildHeader(imp, m) local tabRight = m.x + m.w - m.pad local tabGap = math.floor(6 * m.s) local tabRowGap = math.floor(4 * m.s) + + -- the cartridge dropdown, sized to its longest label so switching games + -- never reflows the row + local chrome0 = headerChrome(imp) + local game = currentGame(imp) + local labelW = 0 + for _, g in ipairs(GAME_TABS) do + labelW = math.max(labelW, Kit.textWidth("tab", Strings(g.label))) + end + local dropW = math.min(tabRight - tabLeft, + tabH + labelW + math.floor(34 * m.s)) + chrome0.game.color = game.color + chrome0.game.letter = game.letter + chrome0.game.active = imp.tab == game.id + local gameHot = Kit.hover(tx, ty, dropW, tabH) + local gameDown = gameHot and Kit.mouseDown + -- face "tab" inverts on hover as well as when active, so the caret has to + -- flip with it or it vanishes into the cartridge colour + local gameInvert = chrome0.game.active or gameHot + chrome0.game.ring = gameHot and not chrome0.game.active or nil + btn(imp, tx, ty, dropW, tabH, "tab-game", Strings(game.label), chrome0.game) + do + local cw = math.floor(7 * m.s) + local ccx = tx + dropW - math.floor(14 * m.s) + local ccy = ty + tabH / 2 + (gameDown and math.floor(1 * m.s) or 0) + if love.graphics.polygon then + Theme.col(gameInvert and PAL.inverse or PAL.ink, gameDown and 1 or 0.9) + love.graphics.polygon("fill", + ccx - cw, ccy - cw * 0.5, ccx + cw, ccy - cw * 0.5, ccx, ccy + cw * 0.8) + love.graphics.setColor(1, 1, 1, 1) + end + end + tx = tx + dropW + tabGap + for _, t in ipairs(tabs) do local w = tabH if tx > tabLeft and tx + w > tabRight then @@ -1909,6 +2010,117 @@ end -- ---------------------------------------------------------- find mods panel +-- SKINS tab: pick the on-screen skin, import one, or open the desktop studio. +local function buildSkinsPanel(imp, x, y, w, availH, m) + local skins = imp:_ensureSkins() + local active = imp:_activeSkin() + local gap = m.gap + local cy = y + + if imp._skinNotice then + cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w, + imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s) + end + + -- Studio button. Desktop only: the host supplies the hook nowhere else. + if imp.onOpenSkinStudio then + local label = Strings("Open Skin Studio") + local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s)) + btn(imp, x, cy, bw, m.btnH, "skins-studio", label, { + kind = "accent", font = "small", + action = function() + -- the studio boots the game on Play, so hand it a real cartridge + imp.onOpenSkinStudio(imp.modScope or "red") + end }) + local hint = Strings("Design bezels and button layouts, then test them.") + Kit.text("small", Kit.ellipsize("small", hint, + w - bw - math.floor(12 * m.s)), x + bw + math.floor(12 * m.s), + cy + math.floor((m.btnH - Kit.textHeight("small")) / 2), PAL.muted) + cy = cy + m.btnH + gap + end + + Kit.caption(x, cy, Strings("INSTALLED")) + cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) + + local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s)) + imp._skinGear = imp._skinGear + or love.graphics.newImage("assets/launcher/gear.png") + + -- The row itself is "use this skin"; the gear beside it configures that + -- entry -- the built-in pad opens the drag-a-button layout editor, a skin + -- opens the studio, so neither lands on a screen that cannot edit it. + local function skinRow(key, id, title, detail, selected, configure) + local gearW = configure and rowH or 0 + local rowW = w - (gearW > 0 and (gearW + math.floor(6 * m.s)) or 0) + local ink = rowHit(imp, x, cy, rowW, rowH, selected, key, + function() imp:_useSkin(id) end) + local tagW = selected + and (Kit.textWidth("small", Strings("IN USE")) + math.floor(20 * m.s)) + or math.floor(12 * m.s) + local textW = rowW - math.floor(24 * m.s) - tagW + local tx = x + math.floor(12 * m.s) + local ty = cy + math.floor(7 * m.s) + Kit.text("mono", Kit.ellipsize("mono", title, textW), tx, ty, + ink or PAL.heading) + Kit.text("small", Kit.ellipsize("small", detail, textW), + tx, ty + Kit.textHeight("mono"), ink or PAL.muted) + if selected then + Kit.textRight("small", Strings("IN USE"), x + rowW - math.floor(12 * m.s), + cy + math.floor((rowH - Kit.textHeight("small")) / 2), ink or PAL.green) + end + if configure then + btn(imp, x + w - gearW, cy, gearW, gearW, key .. "-cfg", "", { + face = "invert", image = imp._skinGear, action = configure }) + end + cy = cy + rowH + math.floor(4 * m.s) + end + + skinRow("skin-none", nil, Strings("Built-in pad"), + Strings("The default on-screen buttons."), active == nil, + imp.onEditTouchControls and function() + imp.onEditTouchControls(imp.modScope or "red") + end or nil) + + for _, entry in ipairs(skins) do + local bits = {} + bits[#bits + 1] = entry.source == "user" and Strings("installed") + or Strings("bundled") + if entry.controls > 0 then + bits[#bits + 1] = entry.controls .. " " .. Strings("buttons") + else + bits[#bits + 1] = Strings("bezel only") + end + if entry.pages > 1 then + bits[#bits + 1] = entry.pages .. " " .. Strings("pages") + end + if entry.screen then bits[#bits + 1] = Strings("screen cutout") end + local configure = imp.onOpenSkinStudio and function() + imp.onOpenSkinStudio(imp.modScope or "red", entry.id) + end or nil + skinRow("skin-" .. entry.id, entry.id, entry.id, + table.concat(bits, " \194\183 "), active == entry.id, configure) + end + + if #skins == 0 then + Kit.emptyBox(x, cy, w, math.floor(72 * m.s), + Strings("No skins installed yet.")) + cy = cy + math.floor(72 * m.s) + gap + end + + cy = cy + math.floor(6 * m.s) + local TouchSkin = require("src.core.TouchSkin") + Kit.caption(x, cy, Strings("IMPORT")) + cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) + local boxH = math.floor(76 * m.s) + Kit.card(x, cy, w, boxH, "muted") + Kit.textWrapped("small", Strings( + "Drop a skin .zip on this window to install it, or put a folder in the skins folder of your save directory. RetroArch overlay .cfg files work as-is."), + x + math.floor(14 * m.s), cy + math.floor(12 * m.s), + w - math.floor(28 * m.s), PAL.muted, 3) + Kit.text("small", TouchSkin.USER_ROOT .. "/", x + math.floor(14 * m.s), + cy + boxH - Kit.textHeight("small") - math.floor(10 * m.s), PAL.faint) +end + local function buildFindPanel(imp, x, y, w, availH, m) imp:_ensureFind() imp:_ensureMods() @@ -2780,6 +2992,32 @@ local function buildModScopeModal(imp, m) action = function() imp._modScopePopup = nil end }) end +-- The cartridge dropdown's list. Replaces the four R/B/Y/G tabs, so it is +-- also what a controller reaches after the tab row. +local function buildGameModal(imp, m) + local pad = math.floor(18 * m.s) + local gap = math.floor(8 * m.s) + local w = math.floor(360 * m.s) + local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + + #GAME_TABS * (m.btnH + gap) + m.btnH + pad + local px, py, pw = modalPanel(m, w, h) + local cy = py + pad + Kit.text("button", Strings("Choose game"), px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(12 * m.s) + local chrome = headerChrome(imp) + for _, g in ipairs(GAME_TABS) do + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-" .. g.id, + Strings(g.label), { + face = "tab", font = "small", letter = g.letter, color = g.color, + active = imp.tab == g.id, + action = chrome.tab[g.id] }) + cy = cy + m.btnH + gap + end + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-close", + Strings("Close"), { font = "small", + action = function() imp._gamePopup = nil end }) +end + -- Category filter for FIND MODS. Two columns, because an index can list -- enough categories to overflow a single stacked column on a short window. local function buildFilterModal(imp, m) @@ -3753,6 +3991,7 @@ local function buildModals(imp, m) if imp._profilesPopup then buildProfilesModal(imp, m) return true end if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end if imp._sortPopup then buildSortModal(imp, m) return true end + if imp._gamePopup then buildGameModal(imp, m) return true end if imp._modScopePopup then buildModScopeModal(imp, m) return true end if imp._filterPopup then buildFilterModal(imp, m) return true end if imp._indexManage then buildIndexesModal(imp, m) return true end @@ -3906,6 +4145,8 @@ function LauncherView.draw(imp) buildModsPanel(imp, x, contentY, w, availH, m) elseif imp.tab == "find" then buildFindPanel(imp, x, contentY, w, availH, m) + elseif imp.tab == "skins" then + buildSkinsPanel(imp, x, contentY, w, availH, m) else buildGamePanel(imp, x, contentY, w, availH, m, imp.tab) end diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index c919e104..6360da09 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -49,6 +49,9 @@ local MAP_GROUP_COUNT = 26 -- constants/map_constants.asm NUM_MAP_GROUPS -- EnvironmentColorsPointers, and the Tilesets row only stores a 16-bit -- pointer, so the bank has to come from here. local PAL_MAP_BANK = 0x02 +-- LoadBallIconGFX.gfx (engine/battle/trainer_huds.asm:225-232); bank $0b +-- carries no manifest symbol to resolve it through. +local BALL_ICON_GFX = { 0x0b, 0x41a4 } -- A tileset sheet is 96 tiles (128x48 at 8x8), and its PalMap packs two -- tiles per byte: low nibble first tile, high nibble second (`dn` in the -- tilepal macro). The high bit of each nibble is the VRAM bank, not colour. @@ -4912,6 +4915,14 @@ function RomExtractorGen2:extractMenuGfx() hud.expBarFirstTile = 0x55 hud.expBarCells = 9 + -- Four OAM tiles at $31 -- normal, statused, fainted, empty -- and OBJ + -- colour 0 is transparent (engine/battle/trainer_huds.asm:47-99, :225-232). + local balls = self.symbols["LoadBallIconGFX.gfx"] or BALL_ICON_GFX + self:write2bpp(self.rom:bytes(balls[1], balls[2], 4 * 16), 32, 8, + "battle/hud/balls.png", true) + hud.balls = "assets/generated/battle/hud/balls.png" + hud.ballsFirstTile = 0x31 + -- "HP:" and the bar cells, as a plain 2bpp sheet rather than the ink-on- -- transparent font page: the bar's rule is shade 3 (black) while its fill is -- shade 1/2 (the HP colour), so flattening it to one ink loses the very diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 0cb92170..37fa0868 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1224,6 +1224,7 @@ function RomImporter.new(onComplete, opts) forceImport = opts.forceImport or false, onEditSave = opts.onEditSave, onEditTouchControls = opts.onEditTouchControls, + onOpenSkinStudio = opts.onOpenSkinStudio, isNX = isNX, romImportMode = romImportMode, mobileFileBridge = mobileFileBridge, @@ -1784,7 +1785,12 @@ function RomImporter:filedropped(file) -- readDroppedFile does here. local name = file:getFilename() or "" if name:lower():match("%.zip$") then - self:_installMod(file) + -- On the SKINS tab a zip is a skin; everywhere else it is a mod archive. + if self.tab == "skins" then + self:_installSkinZip(file) + else + self:_installMod(file) + end return end -- A dropped .sav is a battery save: import it to a new slot for the active @@ -2621,7 +2627,7 @@ function RomImporter:resumeAfterOverlay() end function RomImporter:_cycleTab(delta) - local order = { "red", "blue", "yellow", "gold", "mods", "find" } + local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins" } local idx = 1 for i, id in ipairs(order) do if id == self.tab then idx = i; break end @@ -2987,11 +2993,78 @@ function RomImporter:_switchTab(id) self.tab = id self._findSearchFocus = false self:_disarmTextInput() + -- the skins list is cheap and can change behind the launcher's back + -- (an export, a hand-dropped folder), so re-read it on every visit + if id == "skins" then self:_ensureSkins(true) end if GameVersion.VERSIONS[id] then self:_setModScope(id) end end +-- ------- skins tab (touch skins + the desktop Skin Studio) + +function RomImporter:_ensureSkins(force) + if self._skins and not force then return self._skins end + local TouchSkin = require("src.core.TouchSkin") + local out = {} + for _, entry in ipairs(TouchSkin.list()) do + local skin = TouchSkin.load(entry.root, entry.id) + local page = skin and skin.pages[1] + local controls = 0 + for _, ctl in ipairs(page and page.controls or {}) do + if not ctl.decorative then controls = controls + 1 end + end + out[#out + 1] = { + id = entry.id, + source = entry.source, + pages = skin and #skin.pages or 0, + controls = controls, + screen = page ~= nil and page.viewport ~= nil, + ok = skin ~= nil, + } + end + self._skins = out + return out +end + +function RomImporter:_activeSkin() + local opts = require("src.core.SaveData").loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + return tc.skin +end + +function RomImporter:_useSkin(id) + local SaveData = require("src.core.SaveData") + local opts = SaveData.loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + tc.enabled = true + tc.skin = id + opts.touchControls = tc + SaveData.saveOptions(opts) + self._skinNotice = { + ok = true, + text = id and ("Now using " .. id) or "Now using the built-in pad", + } +end + +function RomImporter:_installSkinZip(file) + local TouchSkin = require("src.core.TouchSkin") + local name = file:getFilename() or "" + local data, readError = readDroppedFile(file) + if not data then + self._skinNotice = { ok = false, + text = "Could not read the dropped file: " .. tostring(readError) } + return + end + local id, err = TouchSkin.installArchive(name, data) + self:_ensureSkins(true) + if not id then + self._skinNotice = { ok = false, text = "Import failed: " .. tostring(err) } + return + end + self._skinNotice = { ok = true, text = "Imported " .. id } +end + function RomImporter:_toggleFindSearchFocus() self._findSearchFocus = not self._findSearchFocus if self._findSearchFocus then @@ -3018,6 +3091,13 @@ function RomImporter:_openSettings() self.onEditTouchControls(version) end end + if self.onOpenSkinStudio then + local version = self.tab + hooks.openSkinStudio = function(skinId) + self:_closeSettings() + self.onOpenSkinStudio(version, skinId) + end + end -- The tab the gear was opened on decides the row set: Gold reads a -- different option block entirely, and offering it Gen 1's rows meant a -- dozen controls that changed nothing (see LauncherSettings.gen2Rows). @@ -3514,7 +3594,17 @@ function RomImporter:_setAllMods(want, confirmed) local LauncherMods = require("src.mods.LauncherMods") local ids, experimental = {}, false for _, m in ipairs(self.mods or {}) do - if m.enabled ~= want then + local mismatched = m.enabled ~= want + if not self.modScope and type(m.enabledByVersion) == "table" then + mismatched = false + for _, game in ipairs(GameVersion.ORDER) do + if m.enabledByVersion[game] ~= want then + mismatched = true + break + end + end + end + if mismatched then ids[#ids + 1] = m.id if want and m.experimental then experimental = true end end diff --git a/src/inventory/Bag.lua b/src/inventory/Bag.lua index fcacc0cc..ea0e7d83 100644 --- a/src/inventory/Bag.lua +++ b/src/inventory/Bag.lua @@ -68,14 +68,20 @@ end -- Acquisition-ordered id list (wBagItems). Rebuilt sorted once for -- saves from before the order existed, then maintained incrementally. -function Bag.order(save) +function Bag.order(save, data) local order = save.bagOrder if not order then + local items = (data or require("src.core.Data")).items order = {} for id in pairs(save.inventory) do if not isBadge(id) then table.insert(order, id) end end - table.sort(order) + table.sort(order, function(a, b) + local ia = (items and items[a] and items[a].index) or math.huge + local ib = (items and items[b] and items[b].index) or math.huge + if ia ~= ib then return ia < ib end + return a < b + end) save.bagOrder = order end -- drop stale ids, append unknown ones (defensive against direct @@ -95,6 +101,29 @@ function Bag.order(save) return order end +-- engine/items/switch_items.asm:38 SwitchItemsInBag .below / .above -- the +-- rotate the PACK's SELECT performs, over the rows of ONE pocket. +function Bag.move(save, id, pocket, toIndex, data) + local order = Bag.order(save, data) + local slots, ids = {}, {} + for i = 1, #order do + if pocketOf(order[i], data) == pocket then + slots[#slots + 1] = i + ids[#ids + 1] = order[i] + end + end + local from + for i = 1, #ids do + if ids[i] == id then from = i break end + end + if not from then return false end + local to = math.max(1, math.min(math.floor(tonumber(toIndex) or from), #ids)) + if to == from then return false end + table.insert(ids, to, table.remove(ids, from)) + for i = 1, #slots do order[slots[i]] = ids[i] end + return true +end + -- Add qty of an item; returns false (and adds nothing) when a new slot -- is needed and the bag is full, or when the stack would pass 99 -- (AddItemToInventory's per-slot quantity cap). diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 4935daf9..ff744222 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -15,6 +15,7 @@ local Runtime = require("src.mods.Runtime") local GameViewport = require("src.render.GameViewport") -- leaf module (no renderer dependency), so requiring it here cannot cycle local FaithfulRes = require("src.core.FaithfulRes") +local TouchSkin = require("src.core.TouchSkin") local Renderer = {} @@ -84,7 +85,13 @@ local function displayMetrics() end if dpiX < 1e-6 then dpiX = 1 end if dpiY < 1e-6 then dpiY = 1 end - return ww, wh, pw, ph, dpiX, dpiY + local vx, vy = 0, 0 + local sx, sy, sw, sh = TouchSkin.viewport(pw, ph) + if sw and sw >= 1 and sh >= 1 then + vx, vy = math.floor(sx), math.floor(sy) + pw, ph = math.floor(sw), math.floor(sh) + end + return ww, wh, pw, ph, dpiX, dpiY, vx, vy end function Renderer:init() @@ -274,6 +281,10 @@ function Renderer:worldViewSize() -- this is the same sum with the viewport standing in for the window, so -- both platforms show the same map area at the same zoom. local cap = FaithfulRes.scaleCap() + if not cap and TouchSkin.hasViewport() then + local page = TouchSkin.page() + if not page.viewportExpand then cap = self:fitScale() end + end if cap then local uiw, uih = self:uiSize() pw, ph = uiw * cap, uih * cap @@ -745,7 +756,9 @@ end -- presented through the GBC FX shader as a final pass. function Renderer:endFrame(zones, worldZones) GameViewport.setTarget() - local ww, wh, pw, ph, dpiX, dpiY = displayMetrics() + local ww, wh, pw, ph, dpiX, dpiY, vx, vy = displayMetrics() + local vux, vuy = vx / dpiX, vy / dpiY + local vuw, vuh = pw / dpiX, ph / dpiY -- Sp = integer framebuffer pixels per GB pixel; -- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY). local Sp = self:fitScale() @@ -753,8 +766,8 @@ function Renderer:endFrame(zones, worldZones) local uiw, uih = self:uiSize() local vpw, vph = uiw * Sx, uih * Sy -- Snap the letterbox origin to a framebuffer pixel, then convert to units. - local ox = math.floor((pw - uiw * Sp) / 2) / dpiX - local oy = math.floor((ph - uih * Sp) / 2) / dpiY + local ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX + local oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY -- The UI has its own scale: it steps down as the survey zoom goes out (see -- uiScale), so it can be smaller than the world letterbox. Un-zoomed these -- are identical to Sp/ox/oy and every rect below is what it always was. @@ -770,8 +783,8 @@ function Renderer:endFrame(zones, worldZones) end local Ux, Uy = Up / dpiX, Up / dpiY local uvpw, uvph = uiw * Ux, uih * Uy - local uox = math.floor((pw - uiw * Up) / 2) / dpiX - local uoy = math.floor((ph - uih * Up) / 2) / dpiY + local uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX + local uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY local GBCFX = require("src.render.GBCFX") -- Forced mono/Classic modes still need a whole-screen zone when a state -- exposes no SGB packets (raw DMG canvas), so sendColors can remap. @@ -921,7 +934,7 @@ function Renderer:endFrame(zones, worldZones) -- skipped entirely (nothing drew into it). The UI blit below still -- runs, so dialogs, menus and the HUD sit on top as usual. love.graphics.setColor(1, 1, 1, 1) - love.graphics.setScissor(0, 0, ww, wh) + love.graphics.setScissor(vux, vuy, vuw, vuh) local loveMajor = love.getVersion() if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY) @@ -933,7 +946,7 @@ function Renderer:endFrame(zones, worldZones) local fade = self.worldFadeAlpha if fade and fade > 0 then love.graphics.setColor(0, 0, 0, fade) - love.graphics.rectangle("fill", 0, 0, ww, wh) + love.graphics.rectangle("fill", vux, vuy, vuw, vuh) love.graphics.setColor(1, 1, 1, 1) end elseif self.worldActive then @@ -941,8 +954,8 @@ function Renderer:endFrame(zones, worldZones) local sx, sy = sp / dpiX, sp / dpiY local wvw = self.worldCanvas:getWidth() local wvh = self.worldCanvas:getHeight() - local wox = math.floor((pw - wvw * sp) / 2) / dpiX - local woy = math.floor((ph - wvh * sp) / 2) / dpiY + local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX + local woy = (vy + math.floor((ph - wvh * sp) / 2)) / dpiY -- Tilt mode projects the ground world pass through the perspective mesh -- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone -- scissoring here). drawTiltedWorld returns false when tilt is off or @@ -953,9 +966,9 @@ function Renderer:endFrame(zones, worldZones) Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present) if not projected then if worldZones then - blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh) + blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, vux, vuy, vuw, vuh) else - blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh) + blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, vux, vuy, vuw, vuh) end -- OBP-baked overworld sprites replay on top of the zone pass (GBC -- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass @@ -964,7 +977,7 @@ function Renderer:endFrame(zones, worldZones) local redraws = PaletteFX.spriteRedraws() if redraws[1] then love.graphics.setColor(1, 1, 1, 1) - love.graphics.setScissor(0, 0, ww, wh) + love.graphics.setScissor(vux, vuy, vuw, vuh) local activeShader = nil for _, r in ipairs(redraws) do local wanted = r.colors @@ -996,7 +1009,7 @@ function Renderer:endFrame(zones, worldZones) if self.uprightActive then local M = self.UPRIGHT_MARGIN love.graphics.setColor(1, 1, 1, 1) - love.graphics.setScissor(0, 0, ww, wh) + love.graphics.setScissor(vux, vuy, vuw, vuh) love.graphics.draw(self.uprightCanvas, wox - M * sx, woy - M * sy, 0, sx, sy) love.graphics.setScissor() end @@ -1007,7 +1020,7 @@ function Renderer:endFrame(zones, worldZones) local fade = self.worldFadeAlpha if fade and fade > 0 then love.graphics.setColor(0, 0, 0, fade) - love.graphics.rectangle("fill", 0, 0, ww, wh) + love.graphics.rectangle("fill", vux, vuy, vuw, vuh) love.graphics.setColor(1, 1, 1, 1) end end @@ -1029,7 +1042,7 @@ function Renderer:endFrame(zones, worldZones) -- (and its duplicate #772). if self.battleDim and self.battleDim > 0 then love.graphics.setColor(0, 0, 0, self.battleDim) - for _, r in ipairs(subtractRect({ { 0, 0, ww, wh } }, uox, uoy, uvpw, uvph)) do + for _, r in ipairs(subtractRect({ { vux, vuy, vuw, vuh } }, uox, uoy, uvpw, uvph)) do love.graphics.rectangle("fill", r[1], r[2], r[3], r[4]) end love.graphics.setColor(1, 1, 1, 1) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 987fa90b..75fbbe40 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -26,6 +26,7 @@ local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 } -- up over the still-visible text (YesNoChoicePokeCenter and friends); -- the box then closes and choice(yes) runs instead of onDone. -- opts.defaultNo starts the cursor on NO. +-- opts.choiceLabels / opts.choiceBox: data/yes_no_menu_strings.asm:16 -- opts.auto: texts with no `prompt` (a text_asm/text_end tail, like -- _UsedStrengthText) never wait for a button: once the last page has -- typed out, auto.sound() runs (returning an audio source blocks like @@ -51,6 +52,8 @@ function TextBox.new(game, text, onDone, opts) self.choice = opts and opts.choice self.defaultNo = opts and opts.defaultNo self.choiceNoSound = opts and opts.noSound + self.choiceLabels = opts and opts.choiceLabels + self.choiceBox = opts and opts.choiceBox self.money = opts and opts.money self.auto = opts and opts.auto self.stay = opts and opts.stay @@ -340,6 +343,7 @@ function TextBox:update(dt) self.game.stack:pop() -- this text box, under the choice self.choice(yes) end, { defaultNo = self.defaultNo, noSound = self.choiceNoSound, + labels = self.choiceLabels, box = self.choiceBox, -- this box is anchored below it; the pair moves together anchor = "bottom" })) end diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 104b2c50..e6d58a57 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -134,6 +134,10 @@ function Commands.show_text(ctx, textId, subs, extraOpts) opts[k] = v end end + -- MONEY_BOX (engine/menus/text_box.asm:133) reads the live wallet + if opts and opts.money == true then + opts.money = function() return ctx.save.money end + end ctx.game.stack:push(TextBox.new(ctx.game, text, function() runner:resume() end, opts)) @@ -780,6 +784,16 @@ function Commands.give_money(ctx, amount) ctx.save.money = math.max(0, ctx.save.money + amount) end +-- check_money : HasEnoughMoney (home/money.asm:1) +function Commands.check_money(ctx, amount) + ctx.lastCheck = (ctx.save.money or 0) >= (amount or 0) +end + +-- take_money : SubBCDPredef (engine/math/bcd.asm:193) +function Commands.take_money(ctx, amount) + ctx.save.money = math.max(0, (ctx.save.money or 0) - (amount or 0)) +end + -- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the -- live overworld memory in sync so a scripted home warp from the HoF PC -- does not leave Red's house mats aimed at Indigo Plateau (#103). diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 5d6807f1..b4a5dffb 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -300,7 +300,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) end list.index = math.min(list.index, math.max(1, #list.items)) if extra and extra.evolveTo then - list:close() + -- engine/menus/start_sub_menus.asm:408 .useItem_partyMenu local Evolution = require("src.pokemon.Evolution") -- item_effects.asm ItemUseEvoStone sets wForceEvolution before -- TryEvolvingMon, so a stone evolution's B press is read and @@ -428,11 +428,12 @@ local function pickTargetAndUse(game, battle, id, list) -- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon -- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on -- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM -> - -- party_menu.asm TM/HM type). Stones and other pickOnly items keep the - -- plain HP layout (Gen 1 shows no ABLE/NOT ABLE for them), so gate - -- strictly on def.machine. #210 + -- party_menu.asm TM/HM type). #210 Stones get the same ABLE / NOT ABLE + -- column: ItemUseEvoStone sets EVO_STONE_PARTY_MENU (party_menu.asm:114). if def and def.machine then opts.tmhm = { move = def.machine.move, kind = def.machine.kind } + elseif ItemEffects.isStone(id) then + opts.evoStone = id end require("src.ui.Screens").push(game, "PartyMenu", opts) end diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index e175202b..3f14b5bf 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -23,11 +23,15 @@ function ChoiceBox.new(game, onChoose, opts) -- and docking it to the window edge instead tears it off that screen by -- however far the letterbox sits from the edge. self.anchor = opts and opts.anchor or nil - local box = Theme.choiceBox + local box = (opts and opts.box) or Theme.choiceBox self.tx = (opts and opts.tx) or box.tx self.ty = (opts and opts.ty) or box.ty self.tw = (opts and opts.tw) or box.tw self.th = (opts and opts.th) or box.th + -- TwoOptionMenuStrings rows carry their own labels and a "blank line + -- before first menu item?" flag (data/yes_no_menu_strings.asm:8-16) + self.labels = (opts and opts.labels) or { "YES", "NO" } + self.firstItem = (opts and opts.firstItem) or box.firstItem or 1 return self end @@ -81,10 +85,12 @@ function ChoiceBox:draw() local paper = self.game and self.game.textboxPaper and self.game:textboxPaper() Font.drawBox(tx, ty, tw, th, paper) love.graphics.setColor(0, 0, 0, 1) - Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8) - Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8) + -- advances 2 * SCREEN_WIDTH -- home/text.asm:64 + local row = self.firstItem + Font.draw(Strings(self.labels[1]), (tx + 2) * 8, (ty + row) * 8) + Font.draw(Strings(self.labels[2]), (tx + 2) * 8, (ty + row + 2) * 8) Font.drawCode(Theme.cursor, (tx + 1) * 8, - (ty + (self.index == 1 and 1 or 3)) * 8) + (ty + row + (self.index == 1 and 0 or 2)) * 8) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index b35dcef5..f774dcd0 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -293,23 +293,30 @@ function IntroMovie:update(dt) return end local input = self.game.input - if input:wasPressed("a") or input:wasPressed("start") then - -- CheckForUserInterruption (home/overworld.asm:2395) returns carry only - -- on a fresh START or A -- B alone never skips the intro. - -- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the - -- white hold stands in for that beat before the title is built. - self:exitToTitle() - return - end + -- CheckForUserInterruption (home/overworld.asm:2395) returns carry only + -- on a fresh START or A -- B alone never skips the intro. + local skip = input:wasPressed("a") or input:wasPressed("start") self.timer = self.timer + 1 if self.phase == 1 then + -- the copyright card is a bare DelayFrames, deaf to input (intro.asm:311) if self.timer >= COPYRIGHT_FRAMES then self:startPhase(2) end elseif self.phase == 2 then if self.timer == STAR_START then Sound.play(self.game.data, "Shooting_Star") -- splash.asm:29-30 end + -- intro.asm:325 `jr c, .next` + if skip and self.timer >= STAR_START and self.timer < WAVES_END then + self:startPhase(3) + return + end if self.timer >= SPLASH_FRAMES then self:startPhase(3) end else + -- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the + -- white hold stands in for that beat before the title is built. + if skip then + self:exitToTitle() + return + end -- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing -- (intro.asm:337), so PlayIntroScene's first op is not on the music's -- own frame diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 04c73930..833b5ffb 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -313,6 +313,9 @@ function PartyMenu.new(game, opts) -- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the -- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210 self.tmhm = opts.tmhm + -- Evolution stones: opts.evoStone = item id gives Gen 1's + -- EVO_STONE_PARTY_MENU ABLE / NOT ABLE display (party_menu.asm:114). #1411 + self.evoStone = opts.evoStone self.forceSwitch = opts.forceSwitch self.battle = opts.battle self.party = party -- link/scoped battles pass their local party view @@ -800,6 +803,20 @@ function PartyMenu:draw() else Font.draw(Strings("NOT ABLE"), 88, y + 8) end + elseif self.evoStone then + -- party_menu.asm:114 .evolutionStoneMenu: an EVOLVE_ITEM row matching + -- wEvoStoneItemID, printed in the TM/HM strings' row+1 column+9 slot + local can = false + for _, evo in ipairs(def.evolutions or {}) do + if evo.method == "ITEM" and evo.item == self.evoStone then + can = true break + end + end + if can then + Font.draw(Strings("ABLE"), 120, y + 8) + else + Font.draw(Strings("NOT ABLE"), 88, y + 8) + end else if mon.hp <= 0 then Font.draw(Strings("FNT"), 136, y) diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua new file mode 100644 index 00000000..6c37bef8 --- /dev/null +++ b/src/ui/SkinStudio.lua @@ -0,0 +1,926 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL +local TouchSkin = require("src.core.TouchSkin") +local TouchControls = require("src.core.TouchControls") +local SaveData = require("src.core.SaveData") + +local Studio = {} + +Studio.CANVASES = { + { id = "phone_portrait", label = "Phone portrait", w = 1080, h = 1920 }, + { id = "phone_landscape", label = "Phone landscape", w = 1920, h = 1080 }, + { id = "tablet_portrait", label = "Tablet portrait", w = 1536, h = 2048 }, + { id = "tablet_landscape", label = "Tablet landscape", w = 2048, h = 1536 }, + { id = "steamdeck", label = "Steam Deck", w = 1280, h = 800 }, + { id = "desktop_1080", label = "Desktop 1080p", w = 1920, h = 1080 }, + { id = "ultrawide", label = "Ultrawide 21:9", w = 2560, h = 1080 }, + { id = "sgb_border", label = "Super Game Boy border", w = 256, h = 224, + lockViewport = { x = 48 / 256, y = 40 / 224, w = 160 / 256, h = 144 / 224 } }, +} + +local HANDLE = 7 +local HANDLES = { + { "nw", 0, 0 }, { "n", 0.5, 0 }, { "ne", 1, 0 }, + { "w", 0, 0.5 }, { "e", 1, 0.5 }, + { "sw", 0, 1 }, { "s", 0.5, 1 }, { "se", 1, 1 }, +} + +local GB_ASPECT = 160 / 144 + +local function clamp01(v) + if v ~= v then return 0 end + if v < 0 then return 0 end + if v > 1 then return 1 end + return v +end + +local function round(v) return math.floor(v + 0.5) end + +function Studio.canvas() + return Studio.CANVASES[Studio.canvasIndex] or Studio.CANVASES[1] +end + +function Studio.page() + local skin = Studio.skin + if not skin then return nil end + return skin.pages[Studio.pageIndex] or skin.pages[1] +end + +function Studio.selectedControl() + local page = Studio.page() + if not page then return nil end + return page.controls[Studio.selected] +end + +local function markDirty() + Studio.dirty = true + Studio.status = nil +end + +local function syncActive() + TouchSkin.setActive(Studio.skin) + TouchSkin.pageIndex = Studio.pageIndex +end + +function Studio.setCanvas(index) + local n = #Studio.CANVASES + Studio.canvasIndex = ((index - 1) % n) + 1 + local canvas = Studio.canvas() + local page = Studio.page() + if page and canvas.lockViewport then + page.viewport = { + x = canvas.lockViewport.x, y = canvas.lockViewport.y, + w = canvas.lockViewport.w, h = canvas.lockViewport.h, + } + page.viewportFill = false + markDirty() + end + if page then page.aspect = canvas.w / canvas.h end +end + +function Studio.load(opts) + opts = opts or {} + Studio.onClose = opts.onClose + Studio.onPlay = opts.onPlay + Studio.version = opts.version + Studio.pageIndex = 1 + Studio.selected = nil + Studio.testing = false + Studio.dirty = false + Studio.status = nil + Studio.drag = nil + Studio.pendingPlay = false + Studio.canvasIndex = 1 + Studio.aspectLock = true + Studio.skinIdField = "" + Studio.available = TouchSkin.list() + Studio.imageTarget = "idle" + + TouchControls:init() + TouchControls.active = true + TouchControls.enabled = true + TouchControls:setPreview(true) + + local start = opts.skinId + if not start then + local saved = SaveData.loadOptions() + local tc = type(saved.touchControls) == "table" and saved.touchControls or {} + start = tc.skin + end + if start and TouchSkin.find(start) then + Studio.open(start) + else + Studio.skin = TouchSkin.newSkin("new_skin") + Studio.skinIdField = Studio.skin.id + syncActive() + end +end + +function Studio.open(id) + local entry = TouchSkin.find(id) + if not entry then return false end + local loaded = TouchSkin.load(entry.root, entry.id) + if not loaded then return false end + Studio.skin = TouchSkin.clone(loaded) + Studio.skinIdField = Studio.skin.id + Studio.pageIndex = 1 + Studio.selected = nil + Studio.dirty = false + Studio.images = TouchSkin.listImages(Studio.skin.root) + syncActive() + return true +end + +function Studio.unload() + Studio.pendingPlay = false + TouchSkin.setSurface(nil) + TouchSkin.setActive(nil) + TouchControls:setPreview(false) + TouchControls:reset() + Studio.skin = nil + Studio.onClose = nil + Studio.onPlay = nil + Studio.drag = nil +end + +-- --------------------------------------------------------------- editing + +function Studio.addControl() + local page = Studio.page() + if not page then return end + page.controls[#page.controls + 1] = + TouchSkin.newControl("a", 0.5, 0.5, 0.16, 0.09, "radial") + Studio.selected = #page.controls + markDirty() +end + +function Studio.deleteControl() + local page = Studio.page() + if not page or not Studio.selected then return end + table.remove(page.controls, Studio.selected) + Studio.selected = page.controls[Studio.selected] and Studio.selected + or (#page.controls > 0 and #page.controls or nil) + markDirty() +end + +function Studio.duplicateControl() + local page, ctl = Studio.page(), Studio.selectedControl() + if not page or not ctl then return end + local copy = TouchSkin.newControl(ctl.spec, clamp01(ctl.x + 0.04), + clamp01(ctl.y + 0.04), ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape) + copy.imagePath, copy.image = ctl.imagePath, ctl.image + copy.pressedImagePath, copy.pressedImage = ctl.pressedImagePath, ctl.pressedImage + copy.rangeMod, copy.alphaMod = ctl.rangeMod, ctl.alphaMod + table.insert(page.controls, Studio.selected + 1, copy) + Studio.selected = Studio.selected + 1 + markDirty() +end + +function Studio.cycleBind(dir) + local ctl = Studio.selectedControl() + if not ctl then return end + local list, at = TouchSkin.BINDS, 1 + for i, spec in ipairs(list) do + if spec == ctl.spec then at = i break end + end + TouchSkin.setBind(ctl, list[((at - 1 + dir) % #list) + 1]) + markDirty() +end + +function Studio.cycleImage(dir) + local ctl = Studio.selectedControl() + local page = Studio.page() + if not page then return end + Studio.images = Studio.images or TouchSkin.listImages(Studio.skin.root) + local list = Studio.images + local field = Studio.imageTarget + local owner = (field == "bezel") and page or ctl + if not owner then return end + local key = (field == "pressed") and "pressedImagePath" or "imagePath" + if field == "bezel" then key = "imagePath" end + + local at = 0 + for i, rel in ipairs(list) do + if rel == owner[key] then at = i break end + end + local next_ = at + dir + if next_ < 0 then next_ = #list end + if next_ > #list then next_ = 0 end + local rel = (next_ >= 1) and list[next_] or nil + owner[key] = rel + local img = rel and TouchSkin.resolveImage(Studio.skin.root, rel) or nil + if field == "pressed" then owner.pressedImage = img else owner.image = img end + markDirty() +end + +function Studio.assignImage(rel) + local page, ctl = Studio.page(), Studio.selectedControl() + local img = TouchSkin.resolveImage(Studio.skin.root, rel) + if ctl and Studio.imageTarget == "pressed" then + ctl.pressedImagePath, ctl.pressedImage = rel, img + elseif ctl and Studio.imageTarget == "idle" then + ctl.imagePath, ctl.image = rel, img + elseif page then + page.imagePath, page.image = rel, img + end + Studio.images = TouchSkin.listImages(Studio.skin.root) + Studio.dirty = true +end + +function Studio.filedropped(file) + if not Studio.skin then return end + local path = (file.getFilename and file:getFilename()) or "" + local base = path:match("([^/\\]+)$") or path + if not base:lower():match("%.png$") and not base:lower():match("%.jpe?g$") then + Studio.status = "Drop a PNG or JPG to use it as art." + return + end + local ok, data = pcall(function() + file:open("r") + local bytes = file:read() + file:close() + return bytes + end) + if not ok or not data then + Studio.status = "Could not read " .. base + return + end + local rel, err = TouchSkin.importImage(Studio.skin, base, data) + if not rel then + Studio.status = "Import failed: " .. tostring(err) + return + end + Studio.assignImage(rel) + local where = Studio.selectedControl() + and (Studio.imageTarget == "pressed" and "pressed art" or "idle art") + or "bezel" + Studio.status = "Imported " .. rel .. " as " .. where + Studio.skinIdField = Studio.skin.id +end + +function Studio.detectViewport() + local page = Studio.page() + if not page or not Studio.skin then return end + if Studio.canvas().lockViewport then + Studio.status = "This preset locks the screen position." + return + end + if not page.imagePath then + Studio.status = "Pick a bezel image first." + return + end + local rect, pw, ph = TouchSkin.detectViewport(Studio.skin.root, page.imagePath) + if not rect then + Studio.status = "No transparent screen hole found in " .. page.imagePath + return + end + page.viewport = rect + Studio.status = ("Screen detected: %dx%d px in the bezel art"):format(pw, ph) + Studio.dirty = true +end + +function Studio.toggleViewport() + local page = Studio.page() + if not page then return end + if Studio.canvas().lockViewport then return end + if page.viewport then + page.viewport = nil + else + page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 } + end + markDirty() +end + +function Studio.addPage() + local skin = Studio.skin + if not skin then return end + local page = TouchSkin.newSkin(skin.id).pages[1] + page.name = "page" .. (#skin.pages + 1) + page.index = #skin.pages + 1 + skin.pages[#skin.pages + 1] = page + Studio.pageIndex = #skin.pages + Studio.selected = nil + syncActive() + markDirty() +end + +function Studio.save() + local skin = Studio.skin + if not skin then return end + local id = (Studio.skinIdField or ""):gsub("[^%w_%-]", "") + if id == "" then + Studio.status = "Give the skin a name first." + return + end + local dest, failed = TouchSkin.saveTo(skin, id) + if not dest then + Studio.status = "Save failed: " .. tostring(failed) + return + end + Studio.dirty = false + Studio.available = TouchSkin.list() + Studio.status = "Saved to " .. dest + if type(failed) == "table" and failed[1] then + Studio.status = Studio.status .. " (" .. #failed .. " image(s) not found)" + end +end + +function Studio.export() + local skin = Studio.skin + if not skin then return end + if Studio.dirty then Studio.save() end + local path, missing = TouchSkin.export(skin) + if not path then + Studio.status = "Export failed: " .. tostring(missing) + return + end + Studio.status = "Exported " .. path + Studio.available = TouchSkin.list() +end + +function Studio.play() + local skin = Studio.skin + if not skin then return end + Studio.save() + if Studio.dirty then return end + local opts = SaveData.loadOptions() + local block = type(opts.touchControls) == "table" and opts.touchControls or {} + block.enabled = true + block.skin = skin.id + opts.touchControls = block + SaveData.saveOptions(opts) + -- Handing off here would unload the studio inside its own draw pass and + -- leave the rest of this frame drawing against a dead skin; Studio.update + -- runs it on the next tick instead. + Studio.pendingPlay = true + Studio.status = "Starting the game with " .. skin.id .. "..." +end + +-- ---------------------------------------------------------------- canvas + +function Studio.canvasRect(x, y, w, h) + local canvas = Studio.canvas() + local aspect = canvas.w / canvas.h + local cw, ch = w, w / aspect + if ch > h then ch, cw = h, h * aspect end + return x + (w - cw) * 0.5, y + (h - ch) * 0.5, cw, ch +end + +local function controlRect(page, ctl, r) + local cx, cy, halfW, halfH = + TouchSkin.controlGeometry(page, ctl, r.w, r.h, r.x, r.y) + return cx - halfW, cy - halfH, halfW * 2, halfH * 2 +end + +local function viewportRect(page, r) + local v = page.viewport + if not v then return nil end + return r.x + v.x * r.w, r.y + v.y * r.h, v.w * r.w, v.h * r.h +end + +local function handleRects(bx, by, bw, bh) + local out = {} + for _, h in ipairs(HANDLES) do + out[#out + 1] = { + id = h[1], + x = bx + bw * h[2] - HANDLE * Kit.scale, + y = by + bh * h[3] - HANDLE * Kit.scale, + w = HANDLE * 2 * Kit.scale, h = HANDLE * 2 * Kit.scale, + } + end + return out +end + +local function applyResize(id, bx, by, bw, bh, dx, dy) + if id:find("w") then bx = bx + dx bw = bw - dx end + if id:find("e") then bw = bw + dx end + if id:find("n") then by = by + dy bh = bh - dy end + if id:find("s") then bh = bh + dy end + return bx, by, math.max(4, bw), math.max(4, bh) +end + +function Studio.beginCanvasDrag(mx, my, r) + local page = Studio.page() + if not page then return end + + local ctl = Studio.selectedControl() + if ctl then + local bx, by, bw, bh = controlRect(page, ctl, r) + for _, h in ipairs(handleRects(bx, by, bw, bh)) do + if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then + Studio.drag = { kind = "control-resize", handle = h.id, mx = mx, my = my, + bx = bx, by = by, bw = bw, bh = bh } + return + end + end + end + + local vx, vy, vw, vh = viewportRect(page, r) + if vx and not Studio.canvas().lockViewport then + for _, h in ipairs(handleRects(vx, vy, vw, vh)) do + if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then + Studio.drag = { kind = "viewport-resize", handle = h.id, mx = mx, my = my, + bx = vx, by = vy, bw = vw, bh = vh } + Studio.selected = nil + return + end + end + end + + for i = #page.controls, 1, -1 do + local c = page.controls[i] + local bx, by, bw, bh = controlRect(page, c, r) + if mx >= bx and mx <= bx + bw and my >= by and my <= by + bh then + Studio.selected = i + Studio.drag = { kind = "control-move", mx = mx, my = my, + bx = bx, by = by, bw = bw, bh = bh } + return + end + end + + if vx and not Studio.canvas().lockViewport + and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then + Studio.selected = nil + Studio.drag = { kind = "viewport-move", mx = mx, my = my, + bx = vx, by = vy, bw = vw, bh = vh } + return + end + + Studio.selected = nil +end + +function Studio.updateDrag(mx, my, r) + local d = Studio.drag + local page = Studio.page() + if not d or not page then return end + local dx, dy = mx - d.mx, my - d.my + local bx, by, bw, bh = d.bx, d.by, d.bw, d.bh + + if d.kind == "control-move" or d.kind == "viewport-move" then + bx, by = bx + dx, by + dy + else + bx, by, bw, bh = applyResize(d.handle, bx, by, bw, bh, dx, dy) + end + + if d.kind:find("viewport") and Studio.aspectLock and d.handle then + -- Derive the axis the handle does not drive, or an edge handle fights the + -- lock and appears dead. A corner drives whichever way the pointer moved + -- furthest, so dragging it up/down resizes as readily as left/right. + local byHeight = (d.handle == "n" or d.handle == "s") + if not byHeight and #d.handle > 1 then + byHeight = math.abs(dy) > math.abs(dx) + end + if byHeight then + local newW = bh * GB_ASPECT + if d.handle:find("w") then bx = bx + (bw - newW) + elseif not d.handle:find("e") then bx = bx + (bw - newW) * 0.5 end + bw = newW + else + local newH = bw / GB_ASPECT + if d.handle:find("n") then by = by + (bh - newH) + elseif not d.handle:find("s") then by = by + (bh - newH) * 0.5 end + bh = newH + end + end + + if d.kind:find("control") then + local ctl = Studio.selectedControl() + if not ctl then return end + ctl.x = clamp01(((bx + bw * 0.5) - r.x) / r.w) + ctl.y = clamp01(((by + bh * 0.5) - r.y) / r.h) + ctl.rangeX = math.max(0.002, (bw * 0.5) / r.w) + ctl.rangeY = math.max(0.002, (bh * 0.5) / r.h) + else + page.viewport = { + x = clamp01((bx - r.x) / r.w), y = clamp01((by - r.y) / r.h), + w = math.max(0.02, bw / r.w), h = math.max(0.02, bh / r.h), + } + end + markDirty() +end + +-- ----------------------------------------------------------------- draw + +local function drawCanvas(x, y, w, h) + local page = Studio.page() + local r = { } + r.x, r.y, r.w, r.h = Studio.canvasRect(x, y, w, h) + Studio.lastCanvas = r + if not page then return r end + + Theme.fill(r.x, r.y, r.w, r.h, { 0, 0, 0 }, 1) + + TouchSkin.setSurface(r.x, r.y, r.w, r.h) + syncActive() + + local vx, vy, vw, vh = viewportRect(page, r) + if vx then + local scale = math.min(vw / 160, vh / 144) + local gw, gh = 160 * scale, 144 * scale + local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5 + Theme.fill(vx, vy, vw, vh, { 12, 12, 12 }, 1) + Theme.fill(gx, gy, gw, gh, { 155, 188, 15 }, 1) + Kit.textCenter("small", "160 x 144", gx, gy + gh * 0.5 - Kit.textHeight("small") * 0.5, + gw, { 20, 40, 20 }) + end + + TouchControls:draw() + TouchSkin.setSurface(nil) + + Theme.strokeRounded(r.x, r.y, r.w, r.h, PAL.line, Theme.A.hairline, 1, 2) + + if Studio.testing then return r end + + if vx then + Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2) + Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue) + if not Studio.canvas().lockViewport then + local a = Studio.selectedControl() and 0.45 or 1 + for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do + Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a) + end + end + end + + for i, ctl in ipairs(page.controls) do + local bx, by, bw, bh = controlRect(page, ctl, r) + local selected = (i == Studio.selected) + local c = ctl.decorative and PAL.muted or (selected and PAL.green or PAL.line) + Theme.strokeRounded(bx, by, bw, bh, c, selected and 1 or 0.45, + selected and 2 or 1, ctl.shape == "radial" and bh * 0.5 or 2) + if selected then + for _, hd in ipairs(handleRects(bx, by, bw, bh)) do + Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.green, 1) + end + Kit.text("small", ctl.spec, bx, by - Kit.textHeight("small") - 2 * Kit.scale, + PAL.green) + end + end + return r +end + +local function inspectorBody(x, y, w) + if not Studio.skin then return y end + local page = Studio.page() + local ctl = Studio.selectedControl() + local rowH = math.max(Kit.tapMin(), 30 * Kit.scale) + local gap = 6 * Kit.scale + local cy = y + + Kit.caption(x, cy, "SKIN") + cy = cy + Kit.textHeight("small") + gap + Studio.skinIdField = Kit.textfield("skinid", x, cy, w, rowH, + Studio.skinIdField, "skin name") + cy = cy + rowH + gap + + local third = (w - gap * 2) / 3 + if Kit.button(x, cy, third, rowH, "Save", { id = "save" }) then Studio.save() end + if Kit.button(x + third + gap, cy, third, rowH, "Export", { id = "export" }) then + Studio.export() + end + if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Play", { id = "play" }) then + Studio.play() + end + cy = cy + rowH + gap * 2 + + Kit.caption(x, cy, "OPEN") + cy = cy + Kit.textHeight("small") + gap + local half = (w - gap) / 2 + if Kit.button(x, cy, half, rowH, "New", { id = "new" }) then + Studio.skin = TouchSkin.newSkin("new_skin") + Studio.skinIdField = "new_skin" + Studio.pageIndex, Studio.selected = 1, nil + Studio.images = {} + syncActive() + markDirty() + end + if Kit.button(x + half + gap, cy, half, rowH, + "Load " .. (#Studio.available > 0 and "\226\150\184" or "-"), + { id = "load", enabled = #Studio.available > 0 }) then + Studio.loadIndex = ((Studio.loadIndex or 0) % #Studio.available) + 1 + Studio.open(Studio.available[Studio.loadIndex].id) + end + cy = cy + rowH + gap * 2 + + Kit.caption(x, cy, "PAGE " .. Studio.pageIndex .. " / " .. #(Studio.skin.pages or {})) + cy = cy + Kit.textHeight("small") + gap + if Kit.button(x, cy, half, rowH, "Next page", { id = "pagenext" }) then + Studio.pageIndex = (Studio.pageIndex % #Studio.skin.pages) + 1 + Studio.selected = nil + syncActive() + end + if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then + Studio.addPage() + end + cy = cy + rowH + gap + + if page then + local bezel = page.imagePath or "(none)" + if Kit.button(x, cy, w, rowH, "Bezel: " .. bezel, { id = "bezel" }) then + Studio.imageTarget = "bezel" + Studio.cycleImage(1) + end + cy = cy + rowH + gap + local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF" + if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp", + enabled = not Studio.canvas().lockViewport }) then + Studio.toggleViewport() + end + Studio.aspectLock = Kit.checkbox(x + half + gap, cy, half, rowH, + Studio.aspectLock, "10:9 lock", "aspect") + cy = cy + rowH + gap + if Kit.button(x, cy, w, rowH, "Detect screen from bezel", { id = "detect", + enabled = page.imagePath ~= nil and not Studio.canvas().lockViewport }) then + Studio.detectViewport() + end + cy = cy + rowH + gap * 2 + end + + Kit.caption(x, cy, "CONTROLS") + cy = cy + Kit.textHeight("small") + gap + if Kit.button(x, cy, third, rowH, "Add", { id = "add" }) then Studio.addControl() end + if Kit.button(x + third + gap, cy, third, rowH, "Dup", + { id = "dup", enabled = ctl ~= nil }) then + Studio.duplicateControl() + end + if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Del", + { id = "del", enabled = ctl ~= nil }) then + Studio.deleteControl() + end + cy = cy + rowH + gap + + if not ctl then + Kit.textWrapped("small", page and #page.controls == 0 + and "No controls yet. Add one, then drag it on the canvas." + or "Click a control on the canvas to edit it.", x, cy, w, PAL.muted, 3) + return cy + Kit.textHeight("small") * 3 + end + + local canvas = Studio.canvas() + if Kit.button(x, cy, w, rowH, "Bind: " .. ctl.spec, { id = "bind" }) then + Studio.cycleBind(1) + end + cy = cy + rowH + 2 * Kit.scale + Kit.text("small", TouchSkin.describeBind(ctl.spec), x, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + gap + + if Kit.button(x, cy, half, rowH, "Shape: " .. ctl.shape, { id = "shape" }) then + ctl.shape = ctl.shape == "radial" and "rect" or "radial" + markDirty() + end + if Kit.button(x + half + gap, cy, half, rowH, + string.format("Reach x%.2f", ctl.rangeMod), { id = "rangemod" }) then + ctl.rangeMod = ctl.rangeMod >= 2 and 0.5 or (ctl.rangeMod + 0.25) + markDirty() + end + cy = cy + rowH + gap + + local quarter = (w - gap * 3) / 4 + local fields = { + { "X", round((ctl.x - ctl.rangeX) * canvas.w) }, + { "Y", round((ctl.y - ctl.rangeY) * canvas.h) }, + { "W", round(ctl.rangeX * 2 * canvas.w) }, + { "H", round(ctl.rangeY * 2 * canvas.h) }, + } + for i, f in ipairs(fields) do + local fx = x + (quarter + gap) * (i - 1) + Kit.text("small", f[1], fx, cy, PAL.faint) + local id = "num" .. f[1] + local shown = Studio.editing == id and Studio.editBuf or tostring(f[2]) + local typed = Kit.textfield(id, fx, cy + Kit.textHeight("small"), + quarter, rowH, shown, "0") + if Kit.focus == id then + Studio.editing, Studio.editBuf = id, typed + elseif Studio.editing == id then + Studio.commitField(id, Studio.editBuf) + Studio.editing, Studio.editBuf = nil, nil + end + end + cy = cy + Kit.textHeight("small") + rowH + gap + Kit.text("small", ("canvas %dx%d px"):format(canvas.w, canvas.h), x, cy, PAL.faint) + cy = cy + Kit.textHeight("small") + gap + + local idle = ctl.imagePath or "(none)" + if Kit.button(x, cy, w, rowH, "Idle art: " .. idle, { id = "img" }) then + Studio.imageTarget = "idle" + Studio.cycleImage(1) + end + cy = cy + rowH + gap + local pressed = ctl.pressedImagePath or "(none)" + if Kit.button(x, cy, w, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then + Studio.imageTarget = "pressed" + Studio.cycleImage(1) + end + return cy + rowH +end + +local function drawInspector(x, y, w, h) + local pad = 10 * Kit.scale + Kit.card(x, y, w, h) + + local scroll = Studio.inspectorScroll or 0 + if Kit.hit(x, y, w, h) and (Kit.wheelY or 0) ~= 0 then + scroll = scroll - Kit.wheelY * 48 * Kit.scale + end + local maxScroll = math.max(0, (Studio.inspectorH or 0) - (h - pad * 2)) + scroll = math.max(0, math.min(scroll, maxScroll)) + Studio.inspectorScroll = scroll + + Kit.pushClip(x, y, w, h) + local top = y + pad - scroll + local endY = inspectorBody(x + pad, top, w - pad * 2) or top + Studio.inspectorH = endY - top + Kit.popClip() + + if maxScroll > 0 then + local frac = h / (h + maxScroll) + local barH = math.max(24 * Kit.scale, h * frac) + local barY = y + (h - barH) * (scroll / maxScroll) + Theme.fill(x + w - 4 * Kit.scale, barY, 3 * Kit.scale, barH, PAL.line, 0.4) + end +end + +function Studio.commitField(id, text) + local ctl = Studio.selectedControl() + if not ctl then return end + local n = tonumber(text) + if not n then return end + local canvas = Studio.canvas() + local left = (ctl.x - ctl.rangeX) * canvas.w + local top = (ctl.y - ctl.rangeY) * canvas.h + local wpx = ctl.rangeX * 2 * canvas.w + local hpx = ctl.rangeY * 2 * canvas.h + if id == "numX" then left = n + elseif id == "numY" then top = n + elseif id == "numW" then wpx = math.max(1, n) + elseif id == "numH" then hpx = math.max(1, n) end + ctl.rangeX = (wpx * 0.5) / canvas.w + ctl.rangeY = (hpx * 0.5) / canvas.h + ctl.x = clamp01((left + wpx * 0.5) / canvas.w) + ctl.y = clamp01((top + hpx * 0.5) / canvas.h) + markDirty() +end + +function Studio.draw() + local W, H = love.graphics.getDimensions() + Kit.layout(W, H) + local mx, my = love.mouse.getPosition() + Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel) + Studio.clicked, Studio.wheel = false, 0 + + Theme.fill(0, 0, W, H, PAL.bg, 1) + + local pad = 14 * Kit.scale + local barH = math.max(Kit.tapMin(), 34 * Kit.scale) + pad + + Kit.textBold("title", "Skin Studio", pad, pad * 0.6, PAL.heading) + local titleW = Kit.textWidth("title", "Skin Studio") + pad * 2 + + local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) + local bx = titleW + local canvas = Studio.canvas() + if Kit.button(bx, pad * 0.5, 200 * Kit.scale, btnH, + canvas.label, { id = "canvas" }) then + Studio.setCanvas(Studio.canvasIndex + 1) + end + bx = bx + 208 * Kit.scale + if Kit.button(bx, pad * 0.5, 110 * Kit.scale, btnH, + Studio.testing and "Test: ON" or "Test: OFF", + { id = "test", active = Studio.testing }) then + Studio.testing = not Studio.testing + TouchControls:setPreview(not Studio.testing) + TouchControls:reset() + end + bx = bx + 118 * Kit.scale + + if Studio.dirty then + Kit.text("small", "unsaved", bx, pad * 0.5 + btnH * 0.3, PAL.yellow) + end + + local closeW = 100 * Kit.scale + if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close", + { id = "close" }) then + if Studio.onClose then Studio.onClose() end + Kit.endFrame() + return + end + + local panelW = math.min(360 * Kit.scale, W * 0.34) + local bodyY = barH + pad * 0.5 + local bodyH = H - bodyY - pad + + drawInspector(pad, bodyY, panelW, bodyH) + + local cx = pad * 2 + panelW + local cw = W - cx - pad + local r = drawCanvas(cx, bodyY, cw, bodyH - 40 * Kit.scale) + + local footY = bodyY + bodyH - 30 * Kit.scale + local msg = Studio.status + if not msg and Studio.testing then + local held = {} + for btn in pairs(TouchControls.held or {}) do held[#held + 1] = btn end + table.sort(held) + msg = "TEST: click the pad. Held: " + .. (held[1] and table.concat(held, ", ") or "(none)") + end + if not msg then + msg = "Drag to move, corner handles to resize, blue box is the game screen." + end + Kit.text("small", Kit.ellipsize("small", msg, cw), cx, footY, PAL.detail) + + Kit.endFrame() + Studio.canvasArea = r +end + +-- ---------------------------------------------------------------- input + +function Studio.update() + if not Studio.pendingPlay then return end + Studio.pendingPlay = false + local onPlay, version, canvas = Studio.onPlay, Studio.version, Studio.canvas() + if onPlay then onPlay(version, canvas) end +end + +function Studio.mousepressed(x, y, button) + if button ~= 1 then return end + Studio.clicked = true + local r = Studio.lastCanvas + if not r then return end + if Studio.testing then + if x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h then + TouchControls:touchpressed("studio", x, y) + end + return + end + local slop = HANDLE * 2 * Kit.scale + if x < r.x - slop or x > r.x + r.w + slop + or y < r.y - slop or y > r.y + r.h + slop then + return + end + Studio.beginCanvasDrag(x, y, r) +end + +function Studio.mousemoved(x, y) + if Studio.testing then + TouchControls:touchmoved("studio", x, y) + return + end + if Studio.drag and love.mouse.isDown(1) and Studio.lastCanvas then + Studio.updateDrag(x, y, Studio.lastCanvas) + end +end + +function Studio.mousereleased(x, y, button) + if button ~= 1 then return end + if Studio.testing then + TouchControls:touchreleased("studio", x, y) + return + end + Studio.drag = nil +end + +function Studio.wheelmoved(_, dy) + Studio.wheel = dy +end + +function Studio.textinput(text) + Kit.textinput(text) +end + +function Studio.keypressed(key) + if Kit.focus then + Kit.keypressed(key) + return + end + if key == "escape" then + if Studio.onClose then Studio.onClose() end + elseif key == "delete" or key == "backspace" then + Studio.deleteControl() + elseif key == "n" then + Studio.addControl() + elseif key == "d" then + Studio.duplicateControl() + elseif key == "t" then + Studio.testing = not Studio.testing + TouchControls:setPreview(not Studio.testing) + TouchControls:reset() + elseif key == "s" then + Studio.save() + elseif key == "tab" then + local page = Studio.page() + if page and #page.controls > 0 then + Studio.selected = ((Studio.selected or 0) % #page.controls) + 1 + end + else + Kit.keypressed(key) + end +end + +function Studio.available_desktop() + local osName = love.system and love.system.getOS and love.system.getOS() + return osName ~= "Android" and osName ~= "iOS" +end + +return Studio diff --git a/src/ui/Theme.lua b/src/ui/Theme.lua index c72992d1..74b9b4d0 100644 --- a/src/ui/Theme.lua +++ b/src/ui/Theme.lua @@ -17,6 +17,12 @@ local Theme = { textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 }, -- InitYesNoTextBoxParameters / AskName: hlcoord 14, 7 (YES_NO_MENU 4x3) choiceBox = { tx = 14, ty = 7, tw = 6, th = 5 }, + -- YesNoChoicePokeCenter: hlcoord 11, 6 (HEAL_CANCEL_MENU 7x4, blank line + -- before the first item) -- home/yes_no.asm:21, data/yes_no_menu_strings.asm:16 + healCancelBox = { tx = 11, ty = 6, tw = 9, th = 6, firstItem = 2 }, + -- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7 + -- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384 + trainerSwitchBox = { tx = 0, ty = 7, tw = 6, th = 5 }, } function Theme.load(data) diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua index be9e0553..f48905f2 100644 --- a/src/ui/TouchControlsEditor.lua +++ b/src/ui/TouchControlsEditor.lua @@ -18,6 +18,7 @@ local SaveData = require("src.core.SaveData") local TouchControls = require("src.core.TouchControls") +local TouchSkin = require("src.core.TouchSkin") local PadCursor = require("src.ui.PadCursor") local GamepadMap = require("src.core.GamepadMap") @@ -46,6 +47,15 @@ local function roundRect(mode, x, y, w, h, r) love.graphics.rectangle(mode, x, y, w, h, r, r) end +local function fitText(font, text, maxW) + text = tostring(text) + if maxW <= 0 or font:getWidth(text) <= maxW then return text end + while #text > 1 and font:getWidth(text .. "...") > maxW do + text = text:sub(1, #text - 1) + end + return text .. "..." +end + function Editor.load(opts) opts = opts or {} Editor.onClose = opts.onClose @@ -53,6 +63,7 @@ function Editor.load(opts) Editor.hostPoll = opts.hostPoll == true Editor.drag = nil Editor.rects = {} + Editor._closed = false Editor._hostMouse = false Editor._hostTouches = nil Editor.fonts = { @@ -74,9 +85,52 @@ function Editor.load(opts) TouchControls:applyOptions(applied) TouchControls:setPreview(true) Editor.enabled = TouchControls.enabled ~= false + Editor.skins = TouchSkin.list() + Editor.exportMsg = nil PadCursor.reset() end +function Editor.skinIndex() + for i, entry in ipairs(Editor.skins or {}) do + if entry.id == TouchControls.skinId then return i + 1 end + end + return 1 +end + +function Editor.skinLabel() + local entry = (Editor.skins or {})[Editor.skinIndex() - 1] + if not entry then return "Built-in pad" end + local skin = TouchSkin.active + local pages = skin and #skin.pages or 1 + if pages > 1 then return entry.id .. " (" .. pages .. " pages)" end + return entry.id +end + +function Editor.cycleSkin(dir) + local n = #(Editor.skins or {}) + 1 + local idx = ((Editor.skinIndex() - 1 + (dir or 1)) % n) + 1 + local entry = Editor.skins[idx - 1] + Editor.exportMsg = nil + TouchControls:selectSkin(entry and entry.id or nil) + TouchControls:ensureImages() +end + +function Editor.exportSkin() + local skin = TouchSkin.active + if not skin then return end + local path, missing = TouchSkin.export(skin) + if not path then + Editor.exportMsg = "Export failed: " .. tostring(missing) + return + end + Editor.exportMsg = "Exported to " .. path .. " in your save folder." + if type(missing) == "table" and missing[1] then + Editor.exportMsg = Editor.exportMsg + .. " " .. #missing .. " image(s) were missing and left out." + end + Editor.skins = TouchSkin.list() +end + function Editor.unload() TouchControls:setPreview(false) TouchControls:reset() @@ -84,6 +138,7 @@ function Editor.unload() Editor.drag = nil Editor.onClose = nil Editor.version = nil + Editor.rects = {} Editor._hostMouse = false Editor._hostTouches = nil end @@ -93,6 +148,7 @@ local function persist() local cfg = TouchControls:config() local block = { enabled = cfg.enabled, + skin = cfg.skin, layouts = cfg.layouts, } if Editor.version == "gold" then @@ -105,6 +161,8 @@ local function persist() end local function close() + if Editor._closed then return end + Editor._closed = true persist() local cb = Editor.onClose Editor.unload() @@ -158,6 +216,8 @@ function Editor.draw() col(PAL.bgTop, 0.85) love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55) + if TouchSkin.active then TouchControls:draw() end + local pad = 18 * s local barH = 56 * s local btnH = 40 * s @@ -170,16 +230,18 @@ function Editor.draw() love.graphics.setLineWidth(1) love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad) - love.graphics.setFont(Editor.fonts.title) - col(PAL.white) - love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s) - -- Done / Reset local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2, w = btnW, h = btnH } local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH } Editor.rects.done, Editor.rects.reset = done, reset + love.graphics.setFont(Editor.fonts.title) + col(PAL.white) + love.graphics.print( + fitText(Editor.fonts.title, "Touch Controls", reset.x - 10 * s - (ox + pad)), + ox + pad, oy + pad + 4 * s) + local function chromeBtn(r, label, fill) col(fill, 0.9) roundRect("fill", r.x, r.y, r.w, r.h, 8 * s) @@ -223,9 +285,48 @@ function Editor.draw() chromeBtn(toggle, on and "Disable" or "Enable", on and PAL.red or PAL.green) + local skinY = cardY + cardH + 10 * s + col(PAL.card, 0.88) + roundRect("fill", cardX, skinY, cardW, cardH, 12 * s) + col(PAL.stroke, 0.4) + roundRect("line", cardX, skinY, cardW, cardH, 12 * s) + + local stepW = 52 * s + local skinNext = { x = cardX + cardW - 16 * s - stepW, + y = skinY + (cardH - btnH) / 2, w = stepW, h = btnH } + local skinPrev = { x = skinNext.x - 10 * s - stepW, y = skinNext.y, + w = stepW, h = btnH } + Editor.rects.skinNext, Editor.rects.skinPrev = skinNext, skinPrev + + local leftmost = skinPrev.x + if TouchSkin.active then + local expW = 86 * s + local export = { x = skinPrev.x - 10 * s - expW, y = skinPrev.y, + w = expW, h = btnH } + Editor.rects.export = export + leftmost = export.x + else + Editor.rects.export = nil + end + + local skinTextW = leftmost - 10 * s - (cardX + 16 * s) + love.graphics.setFont(Editor.fonts.body) + col(PAL.label) + love.graphics.print("Skin", cardX + 16 * s, skinY + 12 * s) + love.graphics.setFont(Editor.fonts.btn) + col(TouchControls.skinId and PAL.green or PAL.white) + love.graphics.print(fitText(Editor.fonts.btn, Editor.skinLabel(), skinTextW), + cardX + 16 * s, skinY + 34 * s) + + chromeBtn(skinPrev, "<", { 60, 70, 110 }) + chromeBtn(skinNext, ">", { 60, 70, 110 }) + if Editor.rects.export then + chromeBtn(Editor.rects.export, "Export", { 60, 70, 110 }) + end + -- size card (#633): -/+ resize every control in the orientation on -- screen; the heading names it so it is plain the other one is untouched - local sizeY = cardY + cardH + 10 * s + local sizeY = skinY + cardH + 10 * s col(PAL.card, 0.88) roundRect("fill", cardX, sizeY, cardW, cardH, 12 * s) col(PAL.stroke, 0.4) @@ -243,7 +344,6 @@ function Editor.draw() string.format("%d%%", math.floor((bucket.scale or 1) * 100 + 0.5)), cardX + 16 * s, sizeY + 34 * s) - local stepW = 52 * s local plus = { x = cardX + cardW - 16 * s - stepW, y = sizeY + (cardH - btnH) / 2, w = stepW, h = btnH } local minus = { x = plus.x - 10 * s - stepW, y = plus.y, w = stepW, h = btnH } @@ -254,13 +354,19 @@ function Editor.draw() -- hint love.graphics.setFont(Editor.fonts.body) col(PAL.label, 0.9) - local hint = on - and "Drag each button to reposition, -/+ to resize. Portrait and landscape are saved separately when you tap Done." - or "Controls are hidden in-game. Enable them to show and edit the layout." + local hint + if Editor.exportMsg then + hint = Editor.exportMsg + elseif not on then + hint = "Controls are hidden in-game. Enable them to show and edit the layout." + elseif TouchSkin.active then + hint = "This skin owns its own art, hitboxes and screen position, so size and dragging are off. Drop a RetroArch overlay folder or .zip into the skins folder of your save directory to add more." + else + hint = "Drag each button to reposition, -/+ to resize. Portrait and landscape are saved separately when you tap Done." + end love.graphics.printf(hint, ox + pad, sizeY + cardH + 12 * s, ww - 2 * pad, "left") - -- the overlay itself (preview mode; dimmed when disabled) - TouchControls:draw() + if not TouchSkin.active then TouchControls:draw() end -- highlight the control under drag if Editor.drag then @@ -282,6 +388,9 @@ local function beginDrag(id, x, y) if inside(Editor.rects.done, x, y) then close(); return end if inside(Editor.rects.reset, x, y) then resetLayout(); return end if inside(Editor.rects.toggle, x, y) then toggleEnabled(); return end + if inside(Editor.rects.skinPrev, x, y) then Editor.cycleSkin(-1); return end + if inside(Editor.rects.skinNext, x, y) then Editor.cycleSkin(1); return end + if inside(Editor.rects.export, x, y) then Editor.exportSkin(); return end if inside(Editor.rects.sizeDown, x, y) then TouchControls:nudgeScale(-TouchControls.SCALE_STEP); return end @@ -289,6 +398,7 @@ local function beginDrag(id, x, y) TouchControls:nudgeScale(TouchControls.SCALE_STEP); return end + if TouchSkin.active then return end local name = TouchControls:hitTest(x, y) if not name then return end local zone = TouchControls:layout()[name] @@ -321,6 +431,7 @@ function Editor.pollHostPointers() if not Editor._hostMouse then Editor._hostMouse = true beginDrag("mouse", x, y) + if Editor._closed then return end else moveDrag("mouse", x, y) end @@ -340,6 +451,7 @@ function Editor.pollHostPointers() if not Editor._hostTouches[id] then Editor._hostTouches[id] = true beginDrag(id, tx, ty) + if Editor._closed then return end else moveDrag(id, tx, ty) end @@ -439,6 +551,12 @@ function Editor.keypressed(key) close() elseif key == "r" then resetLayout() + elseif key == "[" then + Editor.cycleSkin(-1) + elseif key == "]" then + Editor.cycleSkin(1) + elseif key == "e" then + Editor.exportSkin() -- desktop shortcuts for the size -/+ (POKEPORT_TOUCH=1 testing, #633) elseif key == "-" or key == "kp-" then TouchControls:nudgeScale(-TouchControls.SCALE_STEP) diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 95780fed..ac4fac08 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -14,7 +14,9 @@ -- This is what the party-menu FLY field move opens (#195). local Font = require("src.render.Font") +local PaletteFX = require("src.render.PaletteFX") local Sound = require("src.core.Sound") +local SpriteRenderer = require("src.render.SpriteRenderer") local TownMap = {} TownMap.__index = TownMap @@ -227,7 +229,21 @@ function TownMap.new(game, opts) local sprites = game.data.sprites or {} local red = sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED - local ok, img = pcall(love.graphics.newImage, red and red.image) + -- the marker is the overworld walking sheet, so it wears that sheet's OBJ + -- palette and shade-0 keying -- engine/items/town_map.asm:342 + local colors, group + if PaletteFX.usesGbcPack() then + colors, group = PaletteFX.spriteObp(red, "player") + end + if not colors then + if PaletteFX.usesSpriteObp() then + colors, group = PaletteFX.ogObj() + else + colors, group = PaletteFX.dmgObj() + end + end + local ok, img = pcall(SpriteRenderer.obpImage, + red and red.image, colors, group) if ok and img then self.playerSheet = img self.playerQuad = love.graphics.newQuad(0, 0, 16, 16, @@ -316,6 +332,13 @@ function TownMap:update(dt) end end +-- OG RED bakes the boot-ROM OBJ palette in, so the marker has to be replayed +-- over the screen-wide TOWNMAP zone pass the way every other OBJ is (#301) +function TownMap:markPlayerRedraw(x, y) + if not PaletteFX.usesSpriteObp() then return end + PaletteFX.markUiSpriteRedraw(self.playerSheet, self.playerQuad, x, y) +end + local function drawSquare(loc) if isRoute(loc) then love.graphics.setColor(0.62, 0.62, 0.62, 1) -- routes lighter @@ -364,6 +387,7 @@ function TownMap:draw() local x, y = markerXY(self.playerLoc) if self.playerSheet then love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3) + self:markPlayerRedraw(x - 4, y - 3) else love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", x + 2, y + 2, 4, 4) @@ -406,6 +430,8 @@ function TownMap:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(self.playerSheet, self.playerQuad, self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3) + self:markPlayerRedraw(self.playerLoc.x * 8 - 4, + self.playerLoc.y * 8 - 3) else love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2, diff --git a/src/ui/YellowIntro.lua b/src/ui/YellowIntro.lua index 0cfc1446..80b25947 100644 --- a/src/ui/YellowIntro.lua +++ b/src/ui/YellowIntro.lua @@ -256,10 +256,7 @@ function YellowIntro.new(game, onDone) local ok, canvas = pcall(love.graphics.newCanvas, 256, 256) self.bgCanvas = ok and canvas or nil - -- Yellow boots exactly like Red up to the attract movie: the copyright - -- card and the GAME FREAK shooting-star splash play first. Reuse - -- IntroMovie's phases 1-2 and take over where its Gengar fight (phase - -- 3) would begin; a skip press during the pre-roll skips everything. + -- intro.asm:311 copyright card, splash.asm:29 shooting star local IntroMovie = require("src.ui.IntroMovie") local pre = IntroMovie.new(game, nil) local baseStart = pre.startPhase @@ -644,8 +641,8 @@ end -- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land -- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then -- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM --- buffers before the title screen is built (#523). The pre-roll's own skip --- is IntroMovie's path, not this one, and still finishes immediately. +-- buffers before the title screen is built (#523). A press during the +-- pre-roll splash starts the scenes instead of reaching here. function YellowIntro:exitToTitle() if self.finished or self.exiting then return end self.exiting = true diff --git a/src/ui/gen2/BattleHud.lua b/src/ui/gen2/BattleHud.lua index 00afb61b..716ed85d 100644 --- a/src/ui/gen2/BattleHud.lua +++ b/src/ui/gen2/BattleHud.lua @@ -48,6 +48,18 @@ local TILE_PLAYER_BOTTOM_LEFT = 0x6f -- (engine/battle/trainer_huds.asm:143-152). local TILE_CAUGHT = 0x5d +-- The ball icons StageBallTilesData stages, one per party slot +-- (engine/battle/trainer_huds.asm:47-99). +local TILE_BALL_NORMAL = 0x31 +local TILE_BALL_STATUSED = 0x32 +local TILE_BALL_FAINTED = 0x33 +local TILE_BALL_EMPTY = 0x34 +-- DrawPlayerPartyIconHUDBorder's corner (trainer_huds.asm:118-132), which is +-- DrawPlayerHUDBorder's with $77 swapped for $5c. +local TILE_PARTY_ICON_BOTTOM_RIGHT = 0x5c + +BattleHud.PARTY_LENGTH = 6 + function BattleHud.new(menuGfx, palettes) local self = setmetatable({}, BattleHud) self.gfx = menuGfx and menuGfx.battleHud or nil @@ -184,18 +196,25 @@ local TILE_EXP_FULL = 0x6a -- FontBattleExtra local TILE_EXP_EMPTY = 0x62 -- FontBattleExtra local EXP_PARTIAL_BASE = 0x54 -- $54 + remainder lands in ExpBarGFX +-- PAL_BATTLE_BG_EXP, which the attrmap lays over (10,11)..(18,11) +-- (engine/gfx/cgb_layouts.asm:142-145). +function BattleHud:expColors() + local pal = self.palettes and self.palettes.expBar + if not pal then return nil end + return { + { 255, 255, 255 }, + { pal[1][1], pal[1][2], pal[1][3] }, + { pal[2][1], pal[2][2], pal[2][3] }, + { 0, 0, 0 }, + } +end + function BattleHud:drawExpBar(fraction, tx, ty) if not self:image("hpBar") then return false end fraction = math.max(0, math.min(1, fraction or 0)) local pixels = math.floor(fraction * BattleHud.EXP_LENGTH_PX) -- The whole row wears the exp bar's palette, full and empty cells included. - local pal = self.palettes and self.palettes.expBar - local colors = pal and { - { 255, 255, 255 }, - { pal[1][1], pal[1][2], pal[1][3] }, - { pal[2][1], pal[2][2], pal[2][3] }, - { 0, 0, 0 }, - } or nil + local colors = self:expColors() local remaining = pixels for cell = BattleHud.EXP_CELLS - 1, 0, -1 do @@ -269,19 +288,56 @@ end -- DrawPlayerHUDBorder: hlcoord 18, 10 stepping LEFT, tiles $73 / $77 / $6f / -- $76, plus the extra vertical bar DrawPlayerHUD writes at (18,9) so the stub -- is two rows tall. +local PLAYER_FRAME_TILES = { + sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST, + cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST, + -- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet + -- (engine/gfx/load_font.asm:57-65). + farSheet = "enemyBorder", farFirst = ENEMY_BORDER_FIRST, + side = TILE_PLAYER_RIGHT, + nearCorner = TILE_PLAYER_BOTTOM_RIGHT, + farCorner = TILE_PLAYER_BOTTOM_LEFT, + bottom = TILE_BOTTOM_SIDE, +} + function BattleHud:drawPlayerFrame() self:drawTile("playerBorder", PLAYER_BORDER_FIRST, TILE_PLAYER_RIGHT, 18, 9) - self:placeBorder({ - sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST, - cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST, - -- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet - -- (engine/gfx/load_font.asm:57-65). - farSheet = "enemyBorder", farFirst = ENEMY_BORDER_FIRST, - side = TILE_PLAYER_RIGHT, - nearCorner = TILE_PLAYER_BOTTOM_RIGHT, - farCorner = TILE_PLAYER_BOTTOM_LEFT, - bottom = TILE_BOTTOM_SIDE, - }, 18, 10, -1) + self:placeBorder(PLAYER_FRAME_TILES, 18, 10, -1) +end + +-- DrawPlayerPartyIconHUDBorder (engine/battle/trainer_huds.asm:118-132): the +-- player border with $5c for the bottom right, and no bar at (18,9). +function BattleHud:drawPartyIconFrame() + self:placeBorder(PLAYER_FRAME_TILES, 18, 10, -1) + return self:drawTile("expBar", self.gfx and self.gfx.expBarFirstTile, + TILE_PARTY_ICON_BOTTOM_RIGHT, 18, 11, self:expColors()) +end + +-- StageBallTilesData's .GetHUDTile, and the $34 it stages past the party +-- count (engine/battle/trainer_huds.asm:47-100). +local function ballTile(mon) + if not mon then return TILE_BALL_EMPTY end + if (mon.hp or 0) <= 0 then return TILE_BALL_FAINTED end + return mon.status and TILE_BALL_STATUSED or TILE_BALL_NORMAL +end + +-- PAL_BATTLE_OB_YELLOW (engine/battle/trainer_huds.asm:213-214). +function BattleHud:ballColors() + local pals = self.palettes and self.palettes.battleObjects + return pals and pals.PAL_BATTLE_OB_YELLOW or nil +end + +-- LoadTrainerHudOAM (engine/battle/trainer_huds.asm:203-223): six sprites +-- from (tx, ty), each one tile further along `step`. +function BattleHud:drawBallRow(party, tx, ty, step) + if not self:image("balls") then return false end + local first = self.gfx.ballsFirstTile or TILE_BALL_NORMAL + local colors = self:ballColors() + for slot = 1, BattleHud.PARTY_LENGTH do + self:drawTile("balls", first, ballTile(party and party[slot]), + tx + (slot - 1) * step, ty, colors) + end + return true end BattleHud.TILE_HP_LABEL = TILE_HP_LABEL diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index bbbc248c..cd1b7d15 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -314,6 +314,13 @@ function BattleState.new(game, opts) self.showEnemyHud = false self.showPlayerHud = false + -- BattleStart_TrainerHuds, farcalled from BattleStartMessage before the + -- opening line (engine/battle/trainer_huds.asm:1-9, core.asm:8733). + self.ballRows = { + player = true, + enemy = not (self.battle and self.battle.wild), + } + -- InitEnemyTrainer (engine/battle/core.asm:7848) puts the CLASS's 7x7 -- frontpic in the enemy pic box BEFORE the intro slide, and it stays there -- until ResetEnemyBattleVars slides it off; only then is the mon drawn. The @@ -349,6 +356,7 @@ function BattleState.new(game, opts) local enemy = self.battle and self.battle.enemy self:noteFirstUnown(enemy) + self:markSeen(enemy) if enemy then if self.battle.wild then -- BattleCheckEnemyShininess: a shiny wild mon gets ANIM_SEND_OUT_MON's @@ -399,6 +407,13 @@ function BattleState.new(game, opts) -- "X fainted!" is even displayed. The replacement arrives with its own -- `send` event, which is where the cart's send-out animation sits. self.shownMon = { player = player, enemy = enemy } + -- home/battle.asm:150 UpdateBattleHuds + self.shownStatus = { + player = (player and player.status) or false, + enemy = (enemy and enemy.status) or false, + } + -- engine/battle/trainer_huds.asm:142-151 + self.caughtMark = self:dexCaught(enemy) -- And the same for the two numbers AnimateExpBar walks: wBattleMonLevel is -- only advanced inside its level loop, right after that level's bar has -- crawled full (engine/battle/core.asm:7267-7274), so neither the level nor @@ -443,6 +458,16 @@ function BattleState:noteFirstUnown(mon) save.firstUnownSeen = Unown.monLetter(mon) end +-- LoadEnemyMon's "Saw this mon" (engine/battle/core.asm:6203-6209): the seen +-- flag is stamped for every battle mode, so a trainer's mon counts too. +function BattleState:markSeen(mon) + local save = self.save + if not (save and mon and mon.species) then return end + save.pokedex = save.pokedex or { seen = {}, caught = {} } + save.pokedex.seen = save.pokedex.seen or {} + save.pokedex.seen[mon.species] = true +end + -- The DUDE answering a prompt. Every re-arm in the ASM sits at the moment the -- cart starts WAITING for a button (`.wait_input` in home/joypad.asm, BattleMenu -- before LoadBattleMenu, TutorialPack before its own loop), so each one goes @@ -936,6 +961,25 @@ function BattleState:dexCaught(mon) return (mon and caught and caught[mon.species]) and true or false end +-- The status the HUD prints, one drain behind the engine the way shownHp is: +-- UpdateBattleHuds runs after the animation and its line (home/battle.asm:150). +function BattleState:hudStatus(mon, side) + local shown = side and self.shownStatus and self.shownStatus[side] + if shown == nil then return mon and mon.status or nil end + return shown or nil +end + +-- home/battle.asm:150, for the clears no queued event carries (a mon waking, +-- a thaw, a bag cure): the tags catch up once the queue is idle. +function BattleState:syncShownStatus() + local shown, battle = self.shownStatus, self.battle + if not (shown and battle) then return end + for _, side in ipairs({ "player", "enemy" }) do + local mon = battle[side] + shown[side] = (mon and mon.status) or false + end +end + -- The low-HP alarm is not an SFX id at all. PlayDanger (audio/engine.asm:531) -- runs every frame while DANGER_ON_F is set in wLowHealthAlarm and writes a -- two-tone square straight to channel 1 -- DangerSoundHigh ($750) at counter 0, @@ -1153,7 +1197,7 @@ function BattleState:stepAnim(input) -- Cart still reaches the after-anim arm after a move script ends; a skip -- of the move should not drop the hit shake that follows it. if self:startPendingAfterAnim() then return end - return self:endSendOutAnim() + return self:endSendOutAnim(true) end if not self.anim:step() then self:latchCaughtPic() @@ -1170,10 +1214,17 @@ end -- Whatever Call_PlayBattleAnim was standing in front of: a send-out's cry and -- HUD update run the moment its animation is done, cut short or not. -function BattleState:endSendOutAnim() +function BattleState:endSendOutAnim(skipped) local after = self.afterSendOut if not after then return end self.afterSendOut = nil + if after.shiny and not skipped then + after.shiny = nil + if self:animForId("ANIM_SEND_OUT_MON", after.side, 1) then + self.afterSendOut = after + return + end + end self:finishSendOut(after) end @@ -1202,6 +1253,7 @@ function BattleState:advanceQueue() self.introTextShown = nil end if not event then + self:syncShownStatus() -- `jp PlayerSwitch`, which follows the enemy's own send-out and spends no -- turn (engine/battle/core.asm:2955-2963). if self.shiftSwitchIndex then @@ -1315,6 +1367,14 @@ function BattleState:advanceQueue() self.shownHp[event.side] = event.mon.hp or 0 if self.hpAnim and self.hpAnim.side == event.side then self.hpAnim = nil end end + -- And the same lag for the status tag (home/battle.asm:150); a send snaps it + -- to the incoming mon. + if self.shownStatus and event.side + and (event.kind == "status" + or (event.kind == "send" and event.mon)) then + self.shownStatus[event.side] = + (event.kind == "send" and event.mon.status or event.status) or false + end -- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE -- GiveExperiencePoints before the exp is committed (the call at :6888 sits -- ahead of the commit at :6889-6901), so the bar crawls from the figures @@ -1339,7 +1399,12 @@ function BattleState:advanceQueue() -- is still on screen for its own line and the replacement arrives here. if self.shownMon then self.shownMon[event.side] = event.mon end -- The second of the cart's two wFirstUnownSeen writes (core.asm:3251). - if event.side == "enemy" then self:noteFirstUnown(event.mon) end + if event.side == "enemy" then + self:noteFirstUnown(event.mon) + self:markSeen(event.mon) + -- engine/battle/trainer_huds.asm:142-151 + self.caughtMark = self:dexCaught(event.mon) + end if event.side == "player" then -- SendOutPlayerMon zeroes wBattleMenuCursorPosition and wCurMoveNum back -- to back (engine/battle/core.asm:3809), so a switched-in mon opens on @@ -1535,12 +1600,18 @@ end -- SetEnemyTurn / SetPlayerTurn, then ANIM_SEND_OUT_MON. The cry and the HUD -- come after the animation, not with it. function BattleState:startSendOut(side, mon) - local after = { side = side, mon = mon } + -- BattleCheckPlayerShininess / BattleCheckEnemyShininess replay the anim's + -- `.Shiny` arm before the cry (core.asm:3826-3831, :3371-3377). + local after = { side = side, mon = mon, shiny = mon and mon.shiny and true } -- ShowSetEnemyMonAndSendOutAnimation and SendOutPlayerMon both draw the pic -- into the box before they play the animation, which is the one thing that -- undoes a cleared box. self.picHidden[side] = false self.faintSlide = nil + -- The rows are shadow OAM (engine/battle/trainer_huds.asm:203-223), which + -- this animation's own sprites overwrite. + self.ballRows.player = false + self.ballRows.enemy = false if self:animForId("ANIM_SEND_OUT_MON", side) then self.afterSendOut = after return true @@ -2569,6 +2640,9 @@ function BattleState:pushCaught(enemy, itemId) -- in the box", item_effects.asm:624). Boxes.deposit stays an append: the -- PC's own move is InsertPokemonIntoBox, which inserts at the cursor. table.insert(box, 1, enemy) + -- SendMonIntoBox refills the boxed slot's PP before it closes SRAM + -- (move_mon.asm:1062-1063). + Boxes.restorePP(enemy) -- `.SendToPC` re-reads sBoxCount AFTER the insert and sets -- BATTLERESULT_BOX_FULL when the box has just filled -- (item_effects.asm:612-619); Script_reloadmapafterbattle tests that bit @@ -2638,6 +2712,9 @@ end -- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231). function BattleState:offerShiftSwitch(mon) self.shiftIndex = 1 + -- HandleEnemySwitch farcalls EnemySwitch_TrainerHud before the prompt + -- (engine/battle/core.asm:2246, engine/battle/trainer_huds.asm:11-15). + self.ballRows.enemy = true local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe" local player = (self.save and self.save.player and self.save.player.name) or "GOLD" @@ -2921,6 +2998,9 @@ function BattleState:useItem(itemId) end local player = self.battle.player caught, rate = Catching.attempt({ + battle = self.battle, + mon = enemy, + def = enemyDef, maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp), hp = enemy.hp, catchRate = enemyDef and enemyDef.catchRate or 45, @@ -3231,13 +3311,13 @@ function BattleState:drawHud() -- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag -- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops -- the level entirely while one is up. - Chrome.print(self:statusTag(enemy) or ("" .. tostring(enemy.level or 1)), - 6, 1) + Chrome.print(self:statusTag(enemy, "enemy") + or ("" .. tostring(enemy.level or 1)), 6, 1) local enemyGender = self:genderSymbol(enemy) if enemyGender then Chrome.print(enemyGender, 9, 1) end -- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at -- (1,1) (engine/battle/trainer_huds.asm:140-152). - if self.battle and self.battle.wild and self:dexCaught(enemy) then + if self.battle and self.battle.wild and self.caughtMark then self.hud:drawCaughtIcon(1, 1, self:hudHp(enemy, "enemy"), enemy.maxHp or (enemy.stats and enemy.stats.hp)) end @@ -3249,6 +3329,14 @@ function BattleState:drawHud() self:drawFrame(1, 3, 10, false) end end + -- ShowOTTrainerMonsRemaining (engine/battle/trainer_huds.asm:32-45): balls + -- walking LEFT from OAM (72, 32), which the -8/-16 offset puts at (8, 2). + if showStatus and self.ballRows.enemy then + if not self.showEnemyHud and self.hud:available() then + self.hud:drawEnemyFrame() + end + self.hud:drawBallRow(self.battle and self.battle.enemyParty, 8, 2, -1) + end self:drawPic(enemy, false) @@ -3257,6 +3345,14 @@ function BattleState:drawHud() -- the HP bar at (10,9), its numbers below; the vertical bar at (18,9), the -- border from (18,10) going left, and the exp bar at (10,11). self:drawPic(player, true) + -- ShowPlayerMonsRemaining (engine/battle/trainer_huds.asm:17-30): balls + -- walking RIGHT from OAM (96, 96), i.e. tile (11, 10). + if showStatus and self.ballRows.player then + if not self.showPlayerHud and self.hud:available() then + self.hud:drawPartyIconFrame() + end + self.hud:drawBallRow(self.battle and self.battle.party, 11, 10, 1) + end -- No player HUD in the catching tutorial: DrawPlayerHUD lives in -- SendOutPlayerMon, which BATTLETYPE_TUTORIAL jumps straight over, and -- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's @@ -3270,7 +3366,7 @@ function BattleState:drawHud() Chrome.print(self:name(player), 10, 7) -- PrintPlayerHUD places the same status tag at (14,8) and skips the level -- while it is up. - Chrome.print(self:statusTag(player) + Chrome.print(self:statusTag(player, "player") or ("" .. tostring(self.shownLevel or player.level or 1)), 14, 8) local playerGender = self:genderSymbol(player) if playerGender then Chrome.print(playerGender, 17, 8) end @@ -3303,8 +3399,8 @@ local STATUS_TAGS = { paralyze = "PAR", sleep = "SLP", } -function BattleState:statusTag(mon) - return mon and STATUS_TAGS[mon.status] or nil +function BattleState:statusTag(mon, side) + return mon and STATUS_TAGS[self:hudStatus(mon, side)] or nil end -- ♂ / ♀ after the level, or nil for a genderless species (PrintPlayerHUD diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua index 8271c4fc..b019a12b 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -78,8 +78,12 @@ local MOVE_SUBMENU = { "MOVE", "STATS", "CANCEL" } -- engine/pokemon/bills_pc.asm:472-478: BillsPC_Withdraw's menu rows. local WITHDRAW_SUBMENU = { "WITHDRAW", "STATS", "RELEASE", "CANCEL" } +-- BillsPCDepositMenuHeader's .MenuData (engine/pokemon/bills_pc.asm:234-240). +local DEPOSIT_SUBMENU = { "DEPOSIT", "STATS", "RELEASE", "CANCEL" } + function BoxMenu:submenuRows() if self.mode == "move" then return MOVE_SUBMENU end + if self.mode == "deposit" then return DEPOSIT_SUBMENU end return WITHDRAW_SUBMENU end @@ -219,21 +223,12 @@ function BoxMenu:act() if self.onClose then self.onClose() end return end - -- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu. - if self.mode == "move" or self.mode == "withdraw" then - if not self:selected() then return end - self.phase = "submenu" - -- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on MOVE. - self.submenuIndex = 1 - return - end - local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex) - if not ok then - self.message = result - return - end - self.message = nil - self:clampIndex() + -- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu, + -- and _DepositPKMN's .a_button steps to .WhatsUp the same way (:94-102). + if not self:selected() then return end + self.phase = "submenu" + -- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on its top row. + self.submenuIndex = 1 end -- ------------------------------------------------------------ MOVE, step 2 @@ -243,8 +238,9 @@ end -- the PARTY, so a boxed mon walks straight past. Returns true, or false and -- the string the cart places. function BoxMenu:checkMailPreventBlackout() - -- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`. - if not self:isParty() then return true end + -- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`; _DepositPKMN zeroes + -- that byte too, so its list is the party (bills_pc.asm:17-18). + if not (self:isParty() or self.mode == "deposit") then return true end local party = self.save.party or {} -- `cp $3 / jr c, .ItsYourLastPokemon`: a party of one or two may not send -- one away at all, however healthy the rest of it is. @@ -311,10 +307,25 @@ function BoxMenu:doWithdraw() self:clampIndex() end +-- engine/pokemon/bills_pc.asm:155 BillsPCDepositFuncDeposit +function BoxMenu:doDeposit() + local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex) + if not ok then + self.message = result + return + end + self.message = nil + self.phase = nil + self.index, self.scroll = 1, 0 + self:clampIndex() +end + function BoxMenu:chooseSubmenu() local row = self:submenuRows()[self.submenuIndex] if row == "MOVE" then self:beginMove() + elseif row == "DEPOSIT" then + self:doDeposit() elseif row == "WITHDRAW" then self:doWithdraw() elseif row == "STATS" then @@ -393,6 +404,9 @@ function BoxMenu:insertMon() target = target - 1 end table.insert(dest, math.max(1, math.min(target, #dest + 1)), mon) + -- .CopyToBox is InsertPokemonIntoBox, which tails into + -- RestorePPOfDepositedPokemon (engine/pokemon/move_mon_wo_mail.asm:35-37). + if not self:isParty(destIndex) then Boxes.restorePP(mon) end self.phase = nil self.moveFrom, self.backup = nil, nil self.index, self.scroll = 1, 0 @@ -521,6 +535,16 @@ function BoxMenu:askRelease() if self:isCancel() then return end local mon = self:selected() if not mon then return end + -- BillsPCDepositFuncRelease runs the mail/blackout net BEFORE the egg check + -- (engine/pokemon/bills_pc.asm:183-187). + if self.mode == "deposit" then + local allowed, refusal = self:checkMailPreventBlackout() + if not allowed then + self.phase = nil + self.message = refusal + return + end + end -- Both release paths run BillsPC_IsMonAnEgg first, so the question is never -- even asked over an egg (engine/pokemon/bills_pc.asm:186-187 and :427-428). if mon.isEgg then @@ -534,7 +558,12 @@ function BoxMenu:askRelease() local name = mon.nickname or mon.name or mon.species or "?" game.stack:push(ChoiceBox.new(game, function(yes) if not yes then return end - local ok, err = Boxes.release(self.save, self.boxIndex, self.index) + local ok, err + if self.mode == "deposit" then + ok, err = Boxes.releaseFromParty(self.save, self.index) + else + ok, err = Boxes.release(self.save, self.boxIndex, self.index) + end if not ok then self.message = err return diff --git a/src/ui/gen2/ItemPcMenu.lua b/src/ui/gen2/ItemPcMenu.lua index 2ad76be9..3984f95e 100644 --- a/src/ui/gen2/ItemPcMenu.lua +++ b/src/ui/gen2/ItemPcMenu.lua @@ -518,8 +518,9 @@ function ItemPcMenu:drawList() local entry = self.rows[i] if i == self.listIndex then Chrome.cursor(5, ty) end Chrome.print(entry.name, 6, ty) - -- PlaceMenuItemQuantity: the xNN is the entry's second line. - Chrome.print(TIMES .. tostring(entry.count), 7, ty + 1) + -- PlaceMenuItemQuantity (engine/menus/menu_2.asm:24): the xNN is the + -- entry's second line, right-aligned in a blank-padded 2-digit field. + Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1) elseif i == self:listTotal() then if i == self.listIndex then Chrome.cursor(5, ty) end Chrome.print("CANCEL", 6, ty) diff --git a/src/ui/gen2/MagnetTrainRide.lua b/src/ui/gen2/MagnetTrainRide.lua index ce543a7a..dc920687 100644 --- a/src/ui/gen2/MagnetTrainRide.lua +++ b/src/ui/gen2/MagnetTrainRide.lua @@ -12,9 +12,9 @@ -- * The two tilemaps behind DrawMagnetTrain (MagnetTrainBGTiles, a 2x18 -- vertical strip repeated across all 32 columns, and MagnetTrainTilemap, -- the 20x4 train laid over rows 6-9) come from the extracted cache at --- data.field.magnetTrain. A cache built before the extractor learned to --- follow them has neither, and then the ride runs with a blank screen --- rather than with invented art. +-- data.gen2Field.magnetTrain. A cache built before the extractor +-- learned to follow them has neither, and then the ride runs with a +-- blank screen rather than with invented art. -- * The background is baked into a 256x144 canvas once, because the only -- thing that changes per frame is the per-band SCX. -- * SetMagnetTrainPals gives the four bush rows and the four bottom rows @@ -57,7 +57,7 @@ function MagnetTrainRide.new(game, opts) self.onDone = opts.onDone self.finished = false - local field = self.data and self.data.field + local field = self.data and self.data.gen2Field local gfx = field and field.magnetTrain self.ride = MagnetTrain.new({ toGoldenrod = opts.toGoldenrod, @@ -65,8 +65,8 @@ function MagnetTrainRide.new(game, opts) fgTilemap = gfx and gfx.tilemap, }) - self.tileset = self.data and self.data.tilesets - and self.data.tilesets.TILESET_TRAIN_STATION + self.tileset = self.data and self.data.gen2Tilesets + and self.data.gen2Tilesets.TILESET_TRAIN_STATION self.palettes = self:bgPalettes() self.spriteSheet = self:playerSheet() @@ -84,7 +84,7 @@ end -- even though both stations are INDOOR maps. function MagnetTrainRide:bgPalettes() local data = self.data - local palettes = data and data.palettes + local palettes = data and data.gen2Palettes if not palettes then return nil end return Palettes.bgSet(palettes, { environment = "TOWN" }, Palettes.clockDaytime()) @@ -129,7 +129,7 @@ end -- are cut per 8x8 sub-tile rather than by the sheet's 16-pixel width, because -- the OAM data addresses the four tiles of a frame individually. function MagnetTrainRide:playerSheet() - local sprites = self.data and self.data.sprites + local sprites = self.data and self.data.gen2Sprites local def = sprites and (sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS) local path = def and def.image if not path then return nil end @@ -259,8 +259,8 @@ end -- MapObjectPals' PAL_OW_RED, the palette every .OAMData_MagnetTrainRed entry -- names. function MagnetTrainRide:playerPalette() - local palettes = self.data and self.data.palettes - local sprites = self.data and self.data.sprites + local palettes = self.data and self.data.gen2Palettes + local sprites = self.data and self.data.gen2Sprites if not palettes then return nil end return Palettes.spritePalette(palettes, Palettes.clockDaytime(), sprites and sprites.SPRITE_CHRIS) diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 0b200f81..e0e3c507 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -211,6 +211,15 @@ local ROWS = { TC.buzz(options.haptics) if game and game.persistOptions then game:persistOptions() end end }, + { label = "MAX FPS", key = "fpsCap", port = true, + cycle = function(options, delta) + local FrameCap = require("src.core.FrameCap") + options.fpsCap = FrameCap.cycle(options.fpsCap, delta) + FrameCap.apply(options.fpsCap) + end, + text = function(options) + return require("src.core.FrameCap").label(options.fpsCap) + end }, { label = "CANCEL", cancel = true }, } diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index c5f50485..d1e3c2d7 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -67,6 +67,10 @@ local SUBMENU_LABEL = { -- (data/text/common_2.asm), the three lines TossMenu prints in order. local TOSS_HOW_MANY = { "Throw away how", "many?" } +-- _AskItemMoveText (data/text/common_2.asm:322), printed while wSwitchItem +-- holds a row and the cursor is looking for its new home. +local ASK_ITEM_MOVE = { "Where should this", "be moved to?" } + -- _YouDontHaveAMonText and .AnEggCantHoldAnItemText, GiveItem's two refusals. local NO_POKEMON = { "You don't have a", "#MON!" } local EGG_CANT_HOLD = { "An EGG can't hold", "an item." } @@ -127,6 +131,9 @@ function PackMenu.new(game, opts) self.game = game self.save = opts.save or (game and game.save) self.items = opts.items or (game and game.data and game.data.items) + -- Bag.order / Bag.move read `.items` off an injectable data table, and the + -- one this screen draws from is not always the Data singleton. + self.bagData = { items = self.items } self.world = opts.world or (game and game.world) self.onChoose = opts.onChoose self.onClose = opts.onClose @@ -207,7 +214,13 @@ end function PackMenu:rebuild() local pocket = self:pocket().id local rows = {} - for itemId, raw in pairs((self.save and self.save.inventory) or {}) do + local save = self.save + local inventory = (save and save.inventory) or {} + -- Row order IS wBagItems' order, which is what SELECT rewrites. + local order = (save and save.inventory) + and Bag.order(save, self.bagData) or {} + for _, itemId in ipairs(order) do + local raw = inventory[itemId] -- A count that is not a number at all (a hand-written save, a mod, an old -- migration) counts as one rather than raising out of the draw. local count = tonumber(raw) or (raw and 1) or 0 @@ -219,18 +232,13 @@ function PackMenu:rebuild() name = PackMenu.label(itemId, def), teaches = self:moveLabel(def and def.teaches), tmNumber = def and def.tmNumber, - -- KEY_ITEM and TM_HM rows do not show a quantity on the cart. - showCount = pocket == "ITEM" or pocket == "BALL", - index = def and def.index or math.huge, + -- A KEY_ITEM never shows one, and engine/items/tmhm.asm:390 skips the + -- count for an HM only -- a TM prints ×NN like any other stack. + showCount = pocket == "ITEM" or pocket == "BALL" + or (pocket == "TM_HM" and tostring(itemId):sub(1, 3) ~= "HM_"), } end end - -- Bag order on the cart is acquisition order; without that recorded, item id - -- order is the stable, reproducible choice. - table.sort(rows, function(a, b) - if a.index ~= b.index then return a.index < b.index end - return a.id < b.id - end) self.rows = rows self.index = math.min(self.index, #rows + 1) if self.index < 1 then self.index = 1 end @@ -671,6 +679,11 @@ function PackMenu:update(_dt) self:updateQuantity(input) return end + -- engine/items/pack.asm:1237 Pack_InterpretJoypad .switching_item + if self.switching then + self:updateSwitch(input) + return + end if self.message then if input:wasPressed("a") or input:wasPressed("b") then self.message = nil @@ -716,11 +729,52 @@ function PackMenu:update(_dt) end return elseif input:wasPressed("select") then - self:registerSelected() + self:armSwitch() return end end +-- engine/items/pack.asm:1290 Pack_InterpretJoypad .select +function PackMenu:armSwitch() + if self:isCancel() then return end + if not self.rows[self.index] then return end + self.switching = self.index + self.message = ASK_ITEM_MOVE +end + +-- `.switching_item` (engine/items/pack.asm:1297): A or SELECT places, B backs +-- out, and left/right cannot leave the pocket mid-move. +function PackMenu:updateSwitch(input) + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or self:total() + self:ensureVisible() + elseif input:wasPressed("down") then + self.index = self.index < self:total() and self.index + 1 or 1 + self:ensureVisible() + elseif input:wasPressed("a") or input:wasPressed("select") then + self:placeSwitch() + elseif input:wasPressed("b") then + self:endSwitch() + end +end + +-- engine/items/pack.asm:1307 .place_insert / .end_switch +function PackMenu:placeSwitch() + local from = self.switching + local row = self.rows[from] + if row and not self:isCancel() and self.index ~= from then + Bag.move(self.save, row.id, self:pocket().id, self.index, self.bagData) + self:rebuild() + self:storeCursor() + end + self:endSwitch() +end + +function PackMenu:endSwitch() + self.switching = nil + self.message = nil +end + -- VerticalMenu over the submenu rows: up/down wrap, A picks, B is the carry -- that ExitMenu answers with (`ret c`), which is QUIT by another name. function PackMenu:updateSubmenu(input) @@ -777,12 +831,10 @@ function PackMenu:updateConfirm(input) end end --- RegisterItem (engine/items/pack.asm), the submenu's SEL row. SELECT on the --- highlighted row reaches the same routine: the cart's SELECT is the bag's own --- item shuffle, which this port does not have, so the button is free and a --- player who knows Gen 1's registration shortcut gets it. World:registerItem --- re-runs CheckSelectableItem's gate (TM/HM and anything CANT_SELECT_F --- refuses), so neither door can register what the cart would not. +-- RegisterItem (engine/items/pack.asm), the submenu's SEL row and its ONLY +-- door -- the cart's SELECT is the bag's own item shuffle (see armSwitch). +-- World:registerItem re-runs CheckSelectableItem's gate (TM/HM and anything +-- CANT_SELECT_F refuses), so it cannot register what the cart would not. function PackMenu:registerSelected() if self:isCancel() then return end local row = self.rows[self.index] @@ -828,22 +880,38 @@ end -- The list, description and cursor, on top of whatever chrome was drawn. -- --- PlaceMenuItemQuantity (engine/menus/menu_2.asm) writes the ×N one row DOWN --- and one column RIGHT of the name -- the quantity is the entry's second line, --- not a right-aligned column, which is why every PACK row is two tiles tall. +-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:10) writes the ×N one row +-- DOWN and one column RIGHT of the name -- the quantity is the entry's second +-- line, not a right-aligned column, which is why every PACK row is two tiles +-- tall. Its `lb bc, 1, 2` is a TWO-digit field with the leading digit blanked, +-- so the ones digit sits at name + 3 whether the count is 5 or 50. +-- +-- ScrollingMenu_PlaceCursor (engine/menus/scrolling_menu.asm:438) marks the +-- row SELECT armed with the hollow ▷ while the solid ▶ goes on looking. function PackMenu:drawList(listX, listY) for row = 1, VISIBLE_ROWS do local i = row + self.scroll local ty = listY + (row - 1) * LIST_SPACING if i <= #self.rows then local entry = self.rows[i] - if i == self.index then Chrome.cursor(listX - 1, ty) end + if i == self.index then + Chrome.cursor(listX - 1, ty) + elseif i == self.switching then + Chrome.cursor(listX - 1, ty, true) + end Chrome.print(entry.name, listX, ty) if entry.teaches then - -- The TM pocket puts the move the TM teaches on that second line. + -- The TM pocket puts the move the TM teaches on that second line, and + -- its count at listX + 9 (engine/items/tmhm.asm:392) -- on the LABEL's + -- line here, since the move name owns the one below it. Chrome.print(entry.teaches, listX + 1, ty + 1) + if entry.showCount then + Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2), + listX + 9, ty) + end elseif entry.showCount then - Chrome.print("\xc3\x97" .. tostring(entry.count), listX + 1, ty + 1) + Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2), + listX + 1, ty + 1) end elseif i == self:total() then if i == self.index then Chrome.cursor(listX - 1, ty) end @@ -897,12 +965,10 @@ function PackMenu:drawSubmenu() end end --- TossItem_MenuHeader is `menu_coords 15, 9, SCREEN_WIDTH - 1, TEXTBOX_Y - 1` --- with NoPriceToDisplay behind it: a small box in the bottom right holding --- nothing but the count. +-- engine/items/buy_sell_toss.asm:133 BuySellToss_UpdateQuantityDisplay function PackMenu:drawQuantity() Chrome.box(15, 9, 5, 3) - Chrome.print("\xc3\x97" .. tostring(self.qtyState.qty), 16, 10) + Chrome.print("\xc3\x97" .. Chrome.number(self.qtyState.qty, 2, true), 16, 10) end -- YesNoBox's own coords, the same box every other Gen 2 screen here draws. diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index 13a508d3..107c05a7 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -20,6 +20,7 @@ local Chrome = require("src.ui.gen2.Chrome") local Font = require("src.render.Font") local GbcPalette = require("src.render.GbcPalette") local HpBar = require("src.battle.gen2.HpBar") +local ItemEffects = require("src.core.gen2.ItemEffects") local Logger = require("src.core.Logger") local Mail = require("src.core.gen2.Mail") local Mon = require("src.battle.gen2.Mon") @@ -668,17 +669,12 @@ end -- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon -- with no HP reads FNT whatever its status byte says. -local STATUS_STRING = { - slp = "SLP", psn = "PSN", brn = "BRN", frz = "FRZ", par = "PAR", - poison = "PSN", burn = "BRN", freeze = "FRZ", paralysis = "PAR", - sleep = "SLP", toxic = "PSN", -} - local function statusString(mon) if (mon.hp or 0) <= 0 then return "FNT" end local status = mon.status if not status then return nil end - return STATUS_STRING[tostring(status):lower()] + local class = ItemEffects.STATUS_CLASS[tostring(status):lower()] + return class and class:upper() end -- One list row's strings, exactly what WritePartyMenuTilemap's quality diff --git a/src/ui/gen2/PcMenu.lua b/src/ui/gen2/PcMenu.lua index 365481b1..06c724ad 100644 --- a/src/ui/gen2/PcMenu.lua +++ b/src/ui/gen2/PcMenu.lua @@ -24,6 +24,8 @@ local Chrome = require("src.ui.gen2.Chrome") local Logger = require("src.core.Logger") local Mail = require("src.core.gen2.Mail") local Runtime = require("src.mods.Runtime") +local Save = require("src.core.gen2.Save") +local SaveMenu = require("src.ui.gen2.SaveMenu") local Screens = require("src.ui.Screens") local Strings = require("src.core.Strings") @@ -36,6 +38,13 @@ local MON_HOLDING_MAIL = { Strings.source("Please remove the\nMAIL."), } +-- _ChangeBoxSaveText (data/text/common_2.asm:1306) is three lines whose `cont` +-- has already scrolled by the time YesNoBox goes up over its last two. +local CHANGE_BOX_SAVE = { "#MON BOX, data", "will be saved. OK?" } + +-- YesNoBox's own `lb bc, SCREEN_WIDTH - 6, 7` (home/menu.asm:382-383). +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 + local PcMenu = {} PcMenu.__index = PcMenu PcMenu.isOpaque = true @@ -90,6 +99,13 @@ function PcMenu.new(game, opts) self.onClose = opts.onClose self.house = opts.house and true or false self.events = opts.events + -- The same route the start menu's SAVE row takes (src/core/Game2.lua:435), + -- so the save.write veto and the save.writing event fire here too. + self.writer = opts.writer + or (game and type(game.writeSave) == "function" + and function() return game:writeSave() end) + or Save.save + self.saveExists = opts.saveExists -- The folded MAIL BOX row belongs to the item PC, and the whose-PC menu -- reaches that through 's PC (src/ui/gen2/ItemPcMenu.lua), so -- BILL's PC shows the cart's own five rows. The bedroom's PC keeps it: the @@ -156,6 +172,91 @@ function PcMenu:close() if self.onClose then self.onClose(self.changedDecorations) end end +-- engine/pokemon/bills_pc.asm:2403 BillsPC_ChangeBoxSubmenu .Switch +-- engine/menus/save.asm:40 ChangeBoxSaveGame +function PcMenu:beginChangeBox(index) + self.changeBox = index + self.savePhase = "confirm" + self.saveChoice = 1 + self.saveTimer = 0 + self.saved = nil + local existed = self.saveExists + if existed == nil then existed = Save.exists("gold") end + self.existed = existed +end + +-- .refused: `pop de / ret`, with wCurBox untouched and the picker still up. +function PcMenu:refuseChangeBox() + self.savePhase, self.changeBox = nil, nil + self.saveTimer = 0 +end + +function PcMenu:acceptChangeBox() + if self.saveChoice == 2 then return self:refuseChangeBox() end + if self.savePhase == "confirm" and self.existed then + self.savePhase = "overwrite" + self.saveChoice = 1 + return + end + self.savePhase = "saving" + self.saveTimer = 0 +end + +-- `pop de / ld a, e / ld [wCurBox], a` sits between SaveBox and +-- SavingDontTurnOffThePower, so the new index rides the file that is written. +function PcMenu:writeChangeBox() + Boxes.setCurrent(self.save, self.changeBox) + local ok = self.writer(self.save) + self.saved = ok and true or false + if ok then SaveMenu.playSaveSfx(self.game, SaveMenu.SFX_SAVE) end +end + +function PcMenu:savePrompt() + if self.savePhase == "overwrite" then return SaveMenu.OVERWRITE_PROMPT end + if self.savePhase == "saving" then return SaveMenu.SAVING_PROMPT end + if self.savePhase == "done" then + if self.saved then + local name = (self.save.player and self.save.player.name) or "GOLD" + return { name .. " saved", "the game." } + end + return { "Could not save.", "" } + end + return CHANGE_BOX_SAVE +end + +function PcMenu:updateChangeBox() + -- SavingDontTurnOffThePower is DelayFrames, not a prompt: no button does + -- anything until the sequence runs out (engine/menus/save.asm:55). + if self.savePhase == "saving" then + self.saveTimer = self.saveTimer + 1 + if self.saveTimer >= SaveMenu.SAVING_FRAMES then + self:writeChangeBox() + self.savePhase = "done" + self.saveTimer = 0 + end + return + end + if self.savePhase == "done" then + self.saveTimer = self.saveTimer + 1 + if self.saveTimer >= SaveMenu.SAVED_FRAMES then + self.savePhase, self.changeBox = nil, nil + self.picking = false + end + return + end + + local input = self.game and self.game.input + if not input then return end + if input:wasPressed("up") or input:wasPressed("down") then + self.saveChoice = self.saveChoice == 1 and 2 or 1 + elseif input:wasPressed("a") then + self:acceptChangeBox() + elseif input:wasPressed("b") then + -- B out of a yes/no is NO (InterpretTwoOptionMenu returns carry). + self:refuseChangeBox() + end +end + function PcMenu:choose() local entry = self.entries[self.index] if not entry then return end @@ -231,6 +332,11 @@ function PcMenu:update(_dt) return end + if self.savePhase then + self:updateChangeBox() + return + end + if self.picking then local total = Boxes.NUM_BOXES if input:wasPressed("up") then @@ -238,8 +344,11 @@ function PcMenu:update(_dt) elseif input:wasPressed("down") then self.pickIndex = self.pickIndex < total and self.pickIndex + 1 or 1 elseif input:wasPressed("a") then - Boxes.setCurrent(self.save, self.pickIndex) - self.picking = false + if self.pickIndex == (self.save.currentBox or 1) then + self.picking = false + else + self:beginChangeBox(self.pickIndex) + end elseif input:wasPressed("b") then self.picking = false end @@ -287,6 +396,22 @@ function PcMenu:drawPanel() ("%d/%d"):format(Boxes.count(self.save, i), Boxes.MONS_PER_BOX), 18, ty) end + if self.savePhase then + -- ChangeBoxSaveGame's MenuTextbox, then YesNoBox over the box list. + Chrome.box(0, 12, 20, 6) + local lines = self:savePrompt() + Chrome.print(lines[1] or "", 1, 14) + Chrome.print(lines[2] or "", 1, 16) + if self.savePhase == "confirm" or self.savePhase == "overwrite" then + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) + Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, + YESNO_Y + (self.saveChoice == 1 and 1 or 3)) + end + love.graphics.setColor(1, 1, 1, 1) + return + end Chrome.box(0, 14, 20, 4) Chrome.print("Which BOX?", 1, 16) love.graphics.setColor(1, 1, 1, 1) diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index 30bbea33..a7cb56d0 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -2096,10 +2096,23 @@ function Pokegear:mapCursorSprite(x, y) Chrome.cursor(math.floor(x / 8), math.floor(y / 8)) end +-- PokegearRadio_Init's tile $08 at `depixel 4, 10, 4, 4`, three rows deep +-- (data/sprite_anims/oam.asm:588), x = knob (pokegear.asm:1355). +function Pokegear:drawTuningKnob() + self:loadArrowSheet() + if not (self.arrow and self.arrow:available()) then return end + local row = self:currentStation() + local tx = (72 + (row and row.knob or 0)) / 8 + self.arrow:draw(0x08, tx, 1) + self.arrow:draw(0x08, tx, 2) + self.arrow:draw(0x08, tx, 3) +end + function Pokegear:drawRadio() self:ensureTuned() self:drawTilemap(self.gfx and self.gfx.cards and self.gfx.cards.radio) self:drawStrip() + self:drawTuningKnob() local station = self:currentStation() -- UpdateRadioStation prints the tuned channel's name at (2,9). Dead air -- prints nothing: NoRadioStation clears the box and leaves it clear. diff --git a/src/ui/gen2/SaveMenu.lua b/src/ui/gen2/SaveMenu.lua index 3c8868c7..bd745d63 100644 --- a/src/ui/gen2/SaveMenu.lua +++ b/src/ui/gen2/SaveMenu.lua @@ -54,6 +54,11 @@ local TIME_X, TIME_Y = 13, 8 local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5 +-- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and +-- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save. +local OVERWRITE_PROMPT = { "There is already a", "save file. Is it" } +local SAVING_PROMPT = { "SAVING… DON'T TURN", "OFF THE POWER." } + function SaveMenu:wantsFillScale() return true end function SaveMenu:drawsWidescreen() return true end @@ -75,14 +80,18 @@ function SaveMenu.new(game, opts) return self end -function SaveMenu:playSfx(id) - local data = self.game and self.game.data +function SaveMenu.playSaveSfx(game, id) + local data = game and game.data local audio = data and data.audio if not (audio and audio.sfxOrder) then return end local name = audio.sfxOrder[id + 1] if name and audio.sfx and audio.sfx[name] then Sound.play(data, name) end end +function SaveMenu:playSfx(id) + SaveMenu.playSaveSfx(self.game, id) +end + function SaveMenu:finish(saved) if self.onDone then self.onDone(saved) end end @@ -162,10 +171,10 @@ function SaveMenu:prompt() if self.phase == "overwrite" then -- AlreadyASaveFileText when the file is this player's; AnotherSaveFileText -- when the ID differs. Only the first can happen here. - return { "There is already a", "save file. Is it" } + return OVERWRITE_PROMPT end if self.phase == "saving" then - return { "SAVING… DON'T TURN", "OFF THE POWER." } + return SAVING_PROMPT end if self.phase == "done" then if self.saved then @@ -226,4 +235,10 @@ function SaveMenu:drawWidescreen(winW, winH) G.pop() end +SaveMenu.SFX_SAVE = SFX_SAVE +SaveMenu.SAVING_FRAMES = SAVING_FRAMES +SaveMenu.SAVED_FRAMES = SAVED_FRAMES +SaveMenu.OVERWRITE_PROMPT = OVERWRITE_PROMPT +SaveMenu.SAVING_PROMPT = SAVING_PROMPT + return SaveMenu diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index 52f9377f..7f4dc4ee 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -63,6 +63,7 @@ local Chrome = require("src.ui.gen2.Chrome") local Font = require("src.render.Font") local GbcPalette = require("src.render.GbcPalette") local HpBar = require("src.battle.gen2.HpBar") +local ItemEffects = require("src.core.gen2.ItemEffects") local Mon = require("src.battle.gen2.Mon") local Palettes = require("src.world.gen2.Palettes") local Pokerus = require("src.core.gen2.Pokerus") @@ -109,15 +110,6 @@ local TYPE_NAMES = { CURSE_TYPE = "???", } --- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon --- with no HP reads FNT whatever its status byte says. Same table the party --- list uses; both screens call the same routine on the cart. -local STATUS_STRING = { - slp = "SLP", psn = "PSN", brn = "BRN", frz = "FRZ", par = "PAR", - poison = "PSN", burn = "BRN", freeze = "FRZ", paralysis = "PAR", - sleep = "SLP", toxic = "PSN", -} - -- Gen 2 pics are 5x5, 6x6 or 7x7 and PadFrontpic centres the small ones in -- the 7x7 block PrepMonFrontpic lays at hlcoord 0, 0. Same table the dex -- uses, for the same reason. @@ -208,11 +200,15 @@ local function levelText(level) return "" .. tostring(level) end +-- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon +-- with no HP reads FNT whatever its status byte says. Same lookup the party +-- list makes; both screens call the same routine on the cart. local function statusText(mon) if (mon.hp or 0) <= 0 then return "FNT" end local status = mon.status if not status then return nil end - return STATUS_STRING[tostring(status):lower()] + local class = ItemEffects.STATUS_CLASS[tostring(status):lower()] + return class and class:upper() end -- wTempMonPokerusStatus is one byte: the low nibble counts the days left and diff --git a/src/world/NPC.lua b/src/world/NPC.lua index 0c926faf..ef29b7fd 100644 --- a/src/world/NPC.lua +++ b/src/world/NPC.lua @@ -105,8 +105,16 @@ function NPC:update(map, entities) end end +-- UpdateNPCSprite branches to NotYetMoving while BIT_FONT_LOADED is set +-- -- engine/overworld/movement.asm:139 +local function textBoxUp() + local stack = require("src.core.Game").stack + local top = stack and stack.top and stack:top() + return top ~= nil and not top.isOverworld +end + function NPC:walkPhase() - if not self.moving then return 0 end + if not self.moving or textBoxUp() then return 0 end -- engine/overworld/movement.asm:301 local p = (self.animClock or 0) % 16 return (p >= 4 and p < 12) and 1 or 0 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 52374154..bb0a7f3a 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -20,6 +20,7 @@ local Player = require("src.world.Player") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local ScriptRunner = require("src.script.ScriptRunner") +local Theme = require("src.ui.Theme") local Tilt = require("src.render.Tilt") local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") @@ -1109,6 +1110,13 @@ function OverworldState:update(dt) end end + -- EnterMapAnim's .done tail re-enables the companion once the swoop or the + -- spin-down has landed (player_animations.asm:40) + if self.pikachuWarpHidden and not (self.flyAnim or self.flyArrive + or self.teleportOut or self.transitioning or self.player.spinning) then + self:showPikachuAfterWarp() + end + -- Dig/Teleport/Escape-Rope departure spin (beginTeleportOut). The sprite -- spins UP out of the map before the fade (player_animations.asm -- _LeaveMapAnim -> PlayerSpinWhileMovingUp + SFX_TELEPORT_EXIT_1), the @@ -1190,7 +1198,9 @@ function OverworldState:update(dt) or self.engaging or self.emote or self.teleportOut or self.flyAnim or self.flyArrive end - if not scripted and not self.transitioning then + -- a scriptMove's onDone can push a text box on the frame it retires, and + -- DisplayTextID owns the loop from there (home/text_script.asm:3) + if not scripted and not self.transitioning and Game.stack:top() == self then self:handleInput() end @@ -1716,6 +1726,28 @@ function OverworldState:syncSurfingPikachu() p.surfingPikachu = mon ~= nil and mon.species == "PIKACHU" or false end +-- _LeaveMapAnim drops the companion's sprite before the animation starts and +-- EnterMapAnim only puts it back once landed -- home/pikachu.asm:1, :10 +function OverworldState:hidePikachuForWarp() + self.pikachuWarpHidden = true + require("src.world.PikachuFollower").setVisible(self, false) +end + +function OverworldState:showPikachuAfterWarp() + self.pikachuWarpHidden = nil + self.pikachuTrail = { x = self.player.cellX, y = self.player.cellY } + local Follower = require("src.world.PikachuFollower") + local npc = Follower.current(self) + if npc then + npc.cellX, npc.cellY = self.player.cellX, self.player.cellY + npc.px, npc.py = npc.cellX * 16, npc.cellY * 16 + npc.targetX, npc.targetY = nil, nil + npc.goalX, npc.goalY = nil, nil + npc.moving = false + end + Follower.setVisible(self, true) +end + -- The rejection loop shared by the Good and Super Rods -- (item_effects.asm ItemUseGoodRod .RandomLoop / ReadSuperRodData): an -- odd random byte is no bite; otherwise a 2-bit pick rerolls until it @@ -1822,6 +1854,7 @@ function OverworldState:flyTo(mapId) Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE self.player.surfing = false self:syncSurfingPikachu() + self:hidePikachuForWarp() -- _LeaveMapAnim .flyAnimation: the bird flaps in place (8 x Delay3), -- then SFX_FLY and the up-right path, a 40-frame beat off screen, and -- the exit over the top-left -- the warp fades only once the bird is @@ -1852,6 +1885,7 @@ function OverworldState:beginTeleportOut(onDone) require("src.core.Sound").play(Game.data, "Teleport_Exit1") self.player.surfing = false self:syncSurfingPikachu() + self:hidePikachuForWarp() self.player.inputLocked = true -- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so -- spinRise lifts the sprite (Player:pose) while spinFrames counts down @@ -3045,6 +3079,7 @@ function OverworldState:nurseHeal(onDone, npc) end -- Yellow's companion has its own beat threaded through this sequence local Follower = require("src.world.PikachuFollower") + -- YesNoChoicePokeCenter draws HEAL/CANCEL, not YES/NO (home/yes_no.asm:21) Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes) if not yes then Game.stack:push(TextBox.new(Game, bye, onDone)) @@ -3093,7 +3128,7 @@ function OverworldState:nurseHeal(onDone, npc) end end)) end) - end })) + end, choiceLabels = { "HEAL", "CANCEL" }, choiceBox = Theme.healCancelBox })) end -- pokecenter.asm bows the nurse between the two PrintText calls (#995) @@ -4526,6 +4561,11 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) -- down, so the player is never drawable mid-fade nor standing bare on the -- landing frame (#916) self.playerHidden = false + -- setMap respawned a follower under the player; _LeaveMapAnim's Func_1510 + -- suppression runs until EnterMapAnim lands (home/pikachu.asm:1) + if self.pikachuWarpHidden then + require("src.world.PikachuFollower").setVisible(self, false) + end -- The warp we land ON stays inert for the completed-step check until we -- physically step off it, so a warp whose destination cell is itself a -- warp cannot bounce us straight back (elevator cars, stacked stair/door diff --git a/src/world/Player.lua b/src/world/Player.lua index 65406b85..54727a7b 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -226,7 +226,16 @@ function Player:facingCell() return Collision.target(self.cellX, self.cellY, self.facing) end +-- UpdatePlayerSprite jumps to .notMoving while BIT_FONT_LOADED is set +-- -- engine/overworld/movement.asm:57 +local function textBoxUp() + local stack = require("src.core.Game").stack + local top = stack and stack.top and stack:top() + return top ~= nil and not top.isOverworld +end + function Player:walkPhase() + if textBoxUp() then return 0 end -- moving, the land-frame after a completed step, or an active wall-bonk -- (issue #230) animate; a standing sprite otherwise if not self.moving and not self.stepLanded diff --git a/src/world/gen2/MapPreview.lua b/src/world/gen2/MapPreview.lua index d02411ed..9defa1c5 100644 --- a/src/world/gen2/MapPreview.lua +++ b/src/world/gen2/MapPreview.lua @@ -10,10 +10,10 @@ local PixelCanvas = require("src.render.PixelCanvas") local MapPreview = {} +-- home/map.asm:1739-1748 LoadTilesetGFX local ROOF_TILESETS = { TILESET_JOHTO = true, TILESET_JOHTO_MODERN = true, - TILESET_KANTO = true, } local function applyRoofOverlay(atlasPath, roofPath, tilesPerRow) diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 0e0518d5..911ac65b 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -117,6 +117,15 @@ local VAR = { SPECIALPHONECALL = 0x14, } +-- constants/ram_constants.asm:293 wPlayerState. PLAYER_SKATE (2) has no row: +-- nothing writes it, and FieldMoves has no string for it either. +local PLAYER_STATE_BY_ID = { + [0] = FieldMoves.PLAYER_NORMAL, + [1] = FieldMoves.PLAYER_BIKE, + [4] = FieldMoves.PLAYER_SURF, + [8] = FieldMoves.PLAYER_SURF_PIKA, +} + local BATTLETYPE = { CANLOSE = 1, FORCESHINY = 7, @@ -5207,6 +5216,7 @@ function World:dropMapImages(mapId) if key:sub(1, #prefix) == prefix then store[key] = nil end end end + if self.connectionMaps then self.connectionMaps[mapId] = nil end end -- LoadMapAttributes' refill, for every map the session has edited. Neighbour @@ -7480,13 +7490,10 @@ function World:zoomScale() return Zoom.scale(self:fitScale()) end --- Outdoor Johto/Kanto town tilesets are the only ones that swap in roof --- tiles $0a-$12 (mapgroup_roofs.asm). Applying roofs to indoor tilesets --- in the same map group (lab, houses) corrupts their GFX. +-- home/map.asm:1739 LoadTilesetGFX local ROOF_TILESETS = { TILESET_JOHTO = true, TILESET_JOHTO_MODERN = true, - TILESET_KANTO = true, } function World:atlasFor(mapDef) @@ -8724,6 +8731,44 @@ function World:spawnFacing() end end +-- A neighbour map, built once and kept for its collision alone: the seam +-- queries below run on the per-step path. +function World:connectionMap(mapId) + self.connectionMaps = self.connectionMaps or {} + local cached = self.connectionMaps[mapId] + if cached ~= nil then return cached or nil end + local def = self.maps[mapId] + local tileset = def and self.tilesets[def.tileset] + local map = (def and tileset) and Map.new(def, tileset) or false + self.connectionMaps[mapId] = map + return map or nil +end + +-- home/map.asm:1908 GetMovementPermissions +function World:cellCollisionAcross(map, cx, cy) + if map:inBounds(cx, cy) then return map:cellCollision(cx, cy) end + local dir + if cy < 0 then dir = "up" + elseif cy >= map.heightCells then dir = "down" + elseif cx < 0 then dir = "left" + elseif cx >= map.widthCells then dir = "right" + end + local conn = dir and map:connection(DIR_CONN[dir]) + local dest = conn and conn.mapId and self.maps[conn.mapId] + local destMap = dest and self:connectionMap(conn.mapId) + if destMap then + local x, y = Map.connectionLanding(dest, conn, dir, cx, cy) + local vertical = dir == "up" or dir == "down" + local want = (vertical and cx or cy) - (conn.offset or 0) * 2 + -- connectionLanding clamps into the destination; past the end of the strip + -- the buffer still holds this map's own border block + if x and (vertical and x or y) == want then + return destMap:cellCollision(x, y) + end + end + return map:cellCollision(cx, cy) +end + -- Seamless edge cross: swap map data, park the player one cell before the -- landing (same world pixels the neighbor strip already showed), and keep -- the step running so the seam does not hitch. @@ -8736,7 +8781,13 @@ function World:tryConnection(dir) local x, y = Map.connectionLanding( dest, conn, dir, self.player.cellX, self.player.cellY) if not x then return false end - local destMap = Map.new(dest, self.tilesets[dest.tileset]) + local destMap = self:connectionMap(conn.mapId) + if not destMap then return false end + -- home/map.asm:1946 GetMovementPermissions side-wall arm + if dir == "down" + and Permissions.neighborBlocksDown(dir, destMap:cellCollision(x, y)) then + return false + end -- A surfing crossing lands on water, which isWalkable refuses; the arm the -- step would have taken is what decides, the same as it does inside the map. local landable @@ -8869,7 +8920,8 @@ function World:movePlayer(dir) -- .CheckLandPerms and .CheckSurfPerms read the same wTilePermissions, so the -- veto applies to walking and surfing alike. local permitted = Permissions.stepPermitted( - function(x, y) return map:cellCollision(x, y) end, p.cellX, p.cellY, dir) + function(x, y) return self:cellCollisionAcross(map, x, y) end, + p.cellX, p.cellY, dir) -- `.CheckNPC`'s IsNPCAtCoord answers for a BIG_OBJECT's whole 2x2 blob, and -- Player:tryMove's entity scan only ever compares the one cell an object -- stands on -- so the three cells the Vermilion Snorlax overhangs are vetoed diff --git a/tests/drivers/fly_pikachu_bug1400_test.lua b/tests/drivers/fly_pikachu_bug1400_test.lua new file mode 100644 index 00000000..319decb9 --- /dev/null +++ b/tests/drivers/fly_pikachu_bug1400_test.lua @@ -0,0 +1,112 @@ +-- Driver: Fly / Teleport must take Pikachu with it (#1400). _LeaveMapAnim +-- drops the companion sprite before the bird flaps (home/pikachu.asm:1) and +-- EnterMapAnim only puts it back once the swoop or the spin has landed, so +-- Pikachu is invisible for the whole sequence and never stands on the landing +-- cell ahead of the player. +-- +-- POKEPORT_DRIVER=tests/drivers/fly_pikachu_bug1400_test.lua \ +-- POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +-- +-- Never add POKEPORT_SPEED; the run needs an imported Yellow cache. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local GameVersion = require("src.core.GameVersion") + local PF = require("src.world.PikachuFollower") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + check("running as Yellow (needs POKEPORT_VERSION=yellow)", + GameVersion.isYellow()) + + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + -- DisablePikachuOverworldSpriteDrawing is what keeps it in the ball + -- (pokeyellow scripts/OaksLab.asm); out of the ball is what follows (#1009) + game.save.pikachuInBall = false + game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) } + game.save.onBike = false + + local function drawn() + local ow = game.overworld + local npc = ow and PF.current(ow) + if not npc then return false end + for _, e in ipairs(ow.entities or {}) do + if e == npc then return true end + end + return false + end + + U.teleport(game, "ROUTE_17", 4, 10, "down") + U.wait(30) + local ow = game.stack:top() + check("Pikachu is out and drawn before the flight", drawn()) + U.shot(game, DIR .. "/bug1400_0_before.png") + + ow:flyTo("PALLET_TOWN") + U.wait(12) -- mid in-place flap + check("hidden during the departure flap", not drawn()) + U.shot(game, DIR .. "/bug1400_1_flap.png") + + U.wait(60) -- the bird's path out + check("still hidden while the bird flies off", not drawn()) + + local guard = 0 + while ow.map.id == "ROUTE_17" and guard < 900 do + guard = guard + 1 + coroutine.yield() + end + guard = 0 + while not ow.flyArrive and guard < 900 do + guard = guard + 1 + coroutine.yield() + end + U.wait(12) -- mid swoop, the frame the old bug showed Pikachu already landed + check("hidden through the arrival swoop", not drawn()) + U.shot(game, DIR .. "/bug1400_2_arrive.png") + + guard = 0 + while ow.flyArrive and guard < 900 do + guard = guard + 1 + coroutine.yield() + end + U.wait(10) + check("back out once the bird has landed", drawn()) + do + local npc = PF.current(ow) + local p = ow.player + check("and it comes back on the player's own cell", + npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY) + end + U.shot(game, DIR .. "/bug1400_3_landed.png") + + -- Dig / Teleport / Escape Rope take the same _LeaveMapAnim path + game.save.lastHeal = { map = "VIRIDIAN_CITY", x = 23, y = 26 } + ow:beginTeleportOut() + U.wait(20) + check("hidden during the teleport-out spin", not drawn()) + U.shot(game, DIR .. "/bug1400_4_spin_out.png") + + guard = 0 + while (ow.teleportOut or ow.player.spinning or ow.transitioning) + and guard < 900 do + guard = guard + 1 + coroutine.yield() + end + U.wait(10) + check("back out once the arrival spin has landed", drawn()) + U.shot(game, DIR .. "/bug1400_5_spin_in.png") + + U.log("Watch the shots in order: Pikachu stands beside the player before") + U.log("the flight, is nowhere on screen for the flap, the fade and the") + U.log("swoop, and only reappears under him once the bird sets him down.") + U.log("A Pikachu standing on the landing cell during the swoop is the bug.") + U.log("The pad is yours -- fly around and watch the departures.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_bug1401_test.lua b/tests/drivers/gold_bug1401_test.lua new file mode 100644 index 00000000..166bcd88 --- /dev/null +++ b/tests/drivers/gold_bug1401_test.lua @@ -0,0 +1,99 @@ +-- #1401: $d9/$da battlergfx loaded the wrong row count +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1401_test.lua love . + +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +-- data/moves/animations.asm:1840 Growl, :3605 Icy Wind +local CASES = { + { move = "GROWL", cmd = "battlergfx_2row", rows = 1, head = 7, feet = 6 }, + { move = "ICY_WIND", cmd = "battlergfx_1row", rows = 2, head = 14, feet = 12 }, +} + +local function sheet(runner, gfx) + for _, entry in ipairs(runner.loaded or {}) do + if entry.gfx == gfx then return entry end + end + return nil +end + +local function liftedStrip(runner) + local structs = runner.objects and runner.objects.structs or {} + for slot = 1, #structs do + local id = structs[slot].objectId + if type(id) == "string" and id:match("PLAYERHEAD") then return id end + if type(id) == "string" and id:match("ENEMYFEET") then return id end + end + return nil +end + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + game.save.party = { Mon.new(game.data, "CYNDAQUIL", 30) } + assert(world:startBattle({ wild = Mon.new(game.data, "PIDGEY", 30) }), + "startBattle failed") + + local battle + for _ = 1, 600 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + assert(battle and battle.battle, "battle screen is not on the stack") + assert(battle.anims and battle.anims.scripts, + "battle_anims.lua has no scripts -- re-import Gold") + + local failed = false + for _, case in ipairs(CASES) do + battle.anim = nil + if not battle:animForMove(case.move, "player") then + U.log("FAIL no animation for", case.move) + failed = true + else + local head, feet + for _ = 1, 400 do + local runner = battle.anim + if not runner then break end + head = head or sheet(runner, "BATTLE_ANIM_GFX_PLAYERHEAD") + feet = feet or sheet(runner, "BATTLE_ANIM_GFX_ENEMYFEET") + U.wait(1) + end + if not (head and feet) then + U.log("FAIL", case.move, "never loaded the battler sheets") + failed = true + else + U.log(("%-9s %-16s rows=%d head=%d feet=%d (want rows=%d head=%d feet=%d)") + :format(case.move, case.cmd, head.rows, head.tiles, feet.tiles, + case.rows, case.head, case.feet)) + if head.rows ~= case.rows or head.tiles ~= case.head + or feet.tiles ~= case.feet then + U.log("FAIL", case.move, "loaded the wrong row count") + failed = true + end + end + end + end + + U.log(failed and "FAIL #1401" or "PASS #1401 battlergfx rows follow the jumptable") + + U.log("look: Growl lifts ONE row of the Cyndaquil's own tiles, not two") + battle.anim = nil + battle:animForMove("GROWL", "player") + for _ = 1, 400 do + if not battle.anim then break end + local strip = liftedStrip(battle.anim) + if strip then + U.log("parked on", strip) + break + end + U.wait(1) + end + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/gold_bug1421_test.lua b/tests/drivers/gold_bug1421_test.lua new file mode 100644 index 00000000..eec12acb --- /dev/null +++ b/tests/drivers/gold_bug1421_test.lua @@ -0,0 +1,161 @@ +-- #1421: the Gold battle HUD ran ahead of its own animations. +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1421_test.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1421 (default) +-- +-- Two symptoms, one seam, and neither can be asserted -- both are "what is on +-- screen while an animation runs": +-- +-- caught a wild mon that is NOT in the dex is caught with a MASTER BALL. +-- The caught mark ($5d at (1,1)) must be absent for every frame of +-- the throw and every line after it, because DrawEnemyHUDBorder -- +-- the only thing that paints it -- is never called again during a +-- capture (engine/battle/trainer_huds.asm:134-151). The control +-- run right after it re-enters a battle with the same species now +-- in the dex, where the mark IS there from the first frame. +-- +-- status THUNDER WAVE on the enemy. PAR may not be on the HUD until the +-- animation and the "is paralyzed!" line are done with, which is +-- where UpdateBattleHuds runs (home/battle.asm:150). +-- +-- Every frame of both animations is logged as `live` (the engine's byte, a +-- whole turn ahead) against `hud` (what the HUD is allowed to print), so the +-- lag is a column of text as well as a strip of pictures. The run ends in a +-- third wild battle with THUNDER WAVE under the cursor: press A, A, A and +-- watch the tag land with its own line. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1421" +local WILD = "PIDGEY" + +local hostVisible = love.visible +love.visible = function(v) if hostVisible then pcall(hostVisible, v) end end + +local function tap(game, button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 6) +end + +local function openBattle(game, opts) + assert(game.world:startBattle(opts), "startBattle failed") + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +local function toMenu(game, screen) + for _ = 1, 400 do + if screen.phase == "menu" then return true end + if screen.battle.over then return false end + tap(game, "a", 2) + end + return screen.phase == "menu" +end + +local function giveMoves(game, mon, moves) + mon.moves = {} + for i, id in ipairs(moves) do + local def = assert(game.data.moves[id], id .. " is not in moves.lua") + mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp } + end + return mon +end + +-- Shoot and log while the screen is busy: `probe` returns the two values the +-- run is about, and the caller gets them for every frame. +local function watch(game, screen, prefix, label, probe, limit) + local frames = 0 + while frames < (limit or 240) do + if frames % 3 == 0 then + U.shot(game, ("%s-%03d.png"):format(prefix, frames)) + local live, hud = probe() + U.log(("[driver] %s f%03d live=%s hud=%s") + :format(label, frames, tostring(live), tostring(hud))) + end + if not screen.anim and screen.phase ~= "resolving" then break end + frames = frames + 1 + U.wait(1) + end + return frames +end + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local save = game.save + save.pokedex = save.pokedex or { seen = {}, caught = {} } + save.pokedex.caught[WILD] = nil + save.inventory = { MASTER_BALL = 5, POKE_BALL = 10 } + save.boxes = nil + save.party = { giveMoves(game, Mon.new(game.data, "CYNDAQUIL", 30), + { "THUNDER_WAVE", "TACKLE" }) } + + ------------------------------------------------------------------ caught + local screen = openBattle(game, { wild = Mon.new(game.data, WILD, 5) }) + assert(toMenu(game, screen), "never reached the battle menu") + U.shot(game, OUT .. "/caught-00-menu.png") + U.log(("[driver] caught: dex before = %s, hud latch = %s") + :format(tostring(save.pokedex.caught[WILD]), tostring(screen.caughtMark))) + + screen:useItem("MASTER_BALL") + watch(game, screen, OUT .. "/caught-01-throw", "caught", + function() + return save.pokedex.caught[WILD] and true or false, screen.caughtMark + end, 200) + + -- The lines that follow the throw: "Gotcha!", the dex entry, the nickname + -- prompt. The mark may not appear on any of them either. + for i = 0, 7 do + U.shot(game, ("%s/caught-02-after-%d.png"):format(OUT, i)) + if screen.phase == "ask-nickname" then tap(game, "b", 4) + else tap(game, "a", 4) end + if screen.phase == "done" or not game.stack:top() then break end + end + U.log(("[driver] caught: dex after = %s, hud latch = %s") + :format(tostring(save.pokedex.caught[WILD]), tostring(screen.caughtMark))) + + for _ = 1, 300 do + if game.world and not (game.stack:top() or {}).battle then break end + tap(game, "a", 2) + end + + ------------------------------------------------------------------ control + -- Same species, now in the dex: the mark IS on the HUD from the first frame + -- the enemy HUD is drawn, because DrawEnemyHUDBorder runs at battle start. + save.party = { giveMoves(game, Mon.new(game.data, "CYNDAQUIL", 30), + { "THUNDER_WAVE", "TACKLE" }) } + screen = openBattle(game, { wild = Mon.new(game.data, WILD, 5) }) + assert(toMenu(game, screen), "never reached the battle menu") + U.shot(game, OUT .. "/control-00-mark.png") + U.log(("[driver] control: dex = %s, hud latch = %s") + :format(tostring(save.pokedex.caught[WILD]), tostring(screen.caughtMark))) + + ------------------------------------------------------------------ status + screen:submit({ kind = "move", move = "THUNDER_WAVE" }) + watch(game, screen, OUT .. "/status-01-anim", "status", + function() + local enemy = screen.battle.enemy + return enemy and enemy.status, screen:hudStatus(enemy, "enemy") + end, 240) + U.shot(game, OUT .. "/status-02-line.png") + for i = 0, 4 do + tap(game, "a", 4) + U.shot(game, ("%s/status-03-after-%d.png"):format(OUT, i)) + end + U.log(("[driver] status: live=%s hud=%s"):format( + tostring(screen.battle.enemy and screen.battle.enemy.status), + tostring(screen:hudStatus(screen.battle.enemy, "enemy")))) + + U.log("[driver] shots in " .. OUT + .. " -- the battle is yours: FIGHT, THUNDER WAVE, watch the tag land") +end diff --git a/tests/drivers/gold_bug1425_test.lua b/tests/drivers/gold_bug1425_test.lua new file mode 100644 index 00000000..b282ad18 --- /dev/null +++ b/tests/drivers/gold_bug1425_test.lua @@ -0,0 +1,97 @@ +-- #1425 (and #1424): the PACK's ×NN column. +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1425_test.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1425 (default) +-- +-- Nothing here can be asserted: the bug IS the column a digit lands in. +-- PlaceMenuItemQuantity's `lb bc, 1, 2` (engine/menus/menu_2.asm:24) is a +-- two-digit field with the leading digit blanked, so a ×5 and a ×50 must have +-- their ones digit in the SAME column; the port printed "×5" hard against the +-- cross. Each pocket is seeded with a one-digit and a two-digit count stacked +-- next to each other so the two rows can be read off against one another, and +-- the TM pocket is here because its rows used to print no count at all. +-- +-- The run ends with the PACK still open on the TM pocket, so a human takes the +-- controls exactly where the screenshots stop. +local U = require("tests.drivers.util") + +local Bag = require("src.inventory.Bag") +local PackMenu = require("src.ui.gen2.PackMenu") + +-- One-digit and two-digit counts side by side in every pocket that shows one. +local SEED = { + { "POTION", 5 }, + { "SUPER_POTION", 50 }, + { "ANTIDOTE", 1 }, + { "FULL_HEAL", 99 }, + { "POKE_BALL", 7 }, + { "GREAT_BALL", 12 }, + { "TM_DYNAMICPUNCH", 1 }, + { "TM_HEADBUTT", 3 }, + { "TM_THUNDER", 24 }, + { "HM_CUT", 1 }, + { "HM_SURF", 1 }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1425" + + local function shot(name) + U.wait(3) + U.shot(game, ("%s/%s.png"):format(out, name)) + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local save = game.save + save.inventory = {} + save.bagOrder = {} + for _, entry in ipairs(SEED) do + local id, count = entry[1], entry[2] + if not (game.data.items and game.data.items[id]) then + U.log("[driver] SKIP", id, "-- not in this cache") + else + save.inventory[id] = count + table.insert(Bag.order(save), id) + end + end + + local pack = PackMenu.new(game, { save = save, world = game.world, + onClose = function() end }) + game.stack:push(pack) + + shot("00-items") -- ×5 over ×50: the ones digits line up + U.tap(game, "right") + shot("01-balls") -- ×7 over ×12 + U.tap(game, "right") + shot("02-key-items") -- no counts at all here + U.tap(game, "right") + shot("03-tmhm") -- TMs carry ×NN, the two HMs carry none + + -- SELECT arms the row instead of registering it (#1427): the hollow ▷ marks + -- TM_HEADBUTT while "Where should this be moved to?" holds the box. + U.tap(game, "down") + U.tap(game, "select") + shot("04-tmhm-armed") + U.tap(game, "up") + shot("05-tmhm-destination") + U.tap(game, "a") + shot("06-tmhm-moved") -- HEADBUTT is now the first TM row + + U.log("[driver] bag order:", table.concat(Bag.order(save), " ")) + + -- Back to the ITEM pocket and into a TOSS, whose own box prints the count + -- with leading zeros (×01, not × 1). + U.tap(game, "left") + U.tap(game, "left") + U.tap(game, "left") + U.tap(game, "a") + U.tap(game, "down") + U.tap(game, "down") + U.tap(game, "a") + shot("07-toss-quantity") + + U.log("[driver] shots in " .. out .. " -- the PACK is yours") +end diff --git a/tests/drivers/gold_bug1428_test.lua b/tests/drivers/gold_bug1428_test.lua new file mode 100644 index 00000000..cbda6184 --- /dev/null +++ b/tests/drivers/gold_bug1428_test.lua @@ -0,0 +1,174 @@ +-- #1428: the Gold battle HUD never drew the party ball rows. +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1428_test.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1428 (default) +-- +-- BattleStart_TrainerHuds (engine/battle/trainer_huds.asm:1-9) runs from +-- inside BattleStartMessage, so the rows are on screen UNDER the opening line +-- and nowhere else: the player's six always, the opponent's only outside a +-- wild battle. EnemySwitch_TrainerHud (:11-15) brings the opponent's row +-- back for the "will you switch?" prompt after one of its mons drops. +-- +-- The party is seeded so all four staged tiles are on screen at once +-- (StageBallTilesData, :47-99): healthy, statused, fainted, and the empty +-- slots past the party count. +-- +-- trainer the intro line, both rows, then the shift prompt after the first +-- enemy mon faints -- the opponent's row again, one ball darkened +-- wild the same intro with only the player's row, per the `dec a / ret z` +-- +-- Nothing here is assertable: the fix IS twelve sprites. The run ends on the +-- trainer battle's own menu with a human holding the controls. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local Trainers = require("src.world.gen2.Trainers") + +local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1428" + +local hostVisible = love.visible +love.visible = function(v) if hostVisible then pcall(hostVisible, v) end end + +local function tap(game, button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 6) +end + +local function openBattle(game, opts) + assert(game.world:startBattle(opts), "startBattle failed") + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +-- Four healthy, one poisoned, one fainted, and one slot left empty: every +-- tile StageBallTilesData can stage, in one row. The lead is handed a +-- damaging move outright, because slot 1 of its level-up set is LEER. +local function seedParty(game) + local party = {} + for i = 1, 5 do + party[i] = Mon.new(game.data, "CYNDAQUIL", 20 + i) + end + party[1].moves = {} + for i, id in ipairs({ "EMBER", "TACKLE" }) do + local def = assert(game.data.moves[id], id .. " is not in moves.lua") + party[1].moves[i] = { id = id, pp = def.pp, maxPp = def.pp } + end + party[4].status = "poison" + party[5].hp = 0 + return party +end + +local function rowState(screen) + local rows = screen.ballRows or {} + return ("player=%s enemy=%s balls=%s"):format( + tostring(rows.player), tostring(rows.enemy), + tostring(screen.hud and screen.hud:image("balls") ~= nil)) +end + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local save = game.save + save.inventory = {} + save.party = seedParty(game) + + --------------------------------------------------------------- trainer + local entry = game.world:trainerParty(36, 1) -- BUG_CATCHER member 1 + assert(entry, "no BUG_CATCHER member 1 in trainers.lua") + entry.party = Trainers.party(game.data, entry) + local screen = openBattle(game, { trainer = entry }) + + -- The opening line: both rows, both borders, and no names or bars yet. + -- Not before the intro slide has settled, or the shots catch the pics + -- mid-transform instead of the HUD. + U.wait(150) + for i = 0, 6 do + U.shot(game, ("%s/trainer-00-intro-%d.png"):format(OUT, i)) + U.log("[driver] intro " .. i .. ": " .. rowState(screen)) + U.wait(6) + end + U.log(("[driver] OT party = %d"):format(#(screen.battle.enemyParty or {}))) + + -- Page through the pic slide and the send-out: the rows go with the OAM the + -- send-out animation takes over. + for i = 0, 7 do + tap(game, "a", 5) + U.shot(game, ("%s/trainer-01-sendout-%d.png"):format(OUT, i)) + end + for _ = 1, 400 do + if screen.phase == "menu" then break end + tap(game, "a", 2) + end + U.shot(game, OUT .. "/trainer-02-menu.png") + U.log("[driver] menu: " .. rowState(screen)) + + --------------------------------------------------------------- shift prompt + -- Drop the enemy's lead so HandleEnemySwitch offers the switch: its row is + -- redrawn for the prompt, with the fainted ball darkened. + if #(screen.battle.enemyParty or {}) > 1 then + for _ = 1, 400 do + if screen.phase == "ask-shift" or screen.phase == "shift-intro" then + break + end + if screen.battle.over then break end + if screen.phase == "menu" then + local enemy = screen.battle.enemy + if enemy then enemy.hp = 1 end + screen:chooseMenu("fight") + U.wait(2) + screen:chooseMove(1) + U.wait(4) + else + tap(game, "a", 6) + end + end + U.log("[driver] shift prompt: phase=" .. tostring(screen.phase)) + for i = 0, 5 do + U.shot(game, ("%s/trainer-03-shift-%d.png"):format(OUT, i)) + U.log("[driver] shift " .. i .. ": phase=" .. tostring(screen.phase) + .. " " .. rowState(screen)) + U.wait(6) + end + -- NO: the enemy sends its next mon and the row goes with the animation. + tap(game, "down", 4) + tap(game, "a", 4) + for i = 0, 5 do + U.shot(game, ("%s/trainer-04-after-shift-%d.png"):format(OUT, i)) + tap(game, "a", 5) + end + else + U.log("[driver] this trainer has one mon; no shift prompt to show") + end + + --------------------------------------------------------------- wild + -- ShowPlayerMonsRemaining runs for a wild battle too; the `dec a / ret z` + -- right after it is what keeps the OT row off. + for _ = 1, 600 do + if not (game.stack:top() or {}).battle then break end + tap(game, "a", 2) + end + save.party = seedParty(game) + screen = openBattle(game, { wild = Mon.new(game.data, "PIDGEY", 5) }) + U.wait(150) + for i = 0, 6 do + U.shot(game, ("%s/wild-00-intro-%d.png"):format(OUT, i)) + U.log("[driver] wild " .. i .. ": " .. rowState(screen)) + U.wait(6) + end + for _ = 1, 400 do + if screen.phase == "menu" then break end + tap(game, "a", 2) + end + U.shot(game, OUT .. "/wild-01-menu.png") + + U.log("[driver] shots in " .. OUT .. " -- the battle is yours") +end diff --git a/tests/drivers/gold_bug1431_test.lua b/tests/drivers/gold_bug1431_test.lua new file mode 100644 index 00000000..5dc08dc9 --- /dev/null +++ b/tests/drivers/gold_bug1431_test.lua @@ -0,0 +1,142 @@ +-- Driver: the shiny sparkle on a SENT OUT mon (#1431). +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1431_test.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1431 (default) +-- +-- SendOutPlayerMon calls BattleCheckPlayerShininess and replays +-- ANIM_SEND_OUT_MON with wBattleAnimParam 1 -- the script's `.Shiny` arm -- +-- between the plain animation and the cry (engine/battle/core.asm:3820-3837); +-- ShowSetEnemyMonAndSendOutAnimation does the same for every enemy send-out +-- (:3364-3383). Only the wild-intro path (BattleStartMessage, :8701-8715) had +-- been ported, so a shiny lead-off, a shiny switch-in and a shiny enemy +-- replacement all came out silent. +-- +-- Three send-outs to watch, none of them the wild intro: the player's lead-off, +-- the player's own switch, and the opponent's replacement after a faint. +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") +local GbcPalette = require("src.render.GbcPalette") + +-- constants/battle_constants.asm: the DV pair BATTLETYPE_FORCESHINY forces. +local SHINY_DVS = { attack = 14, defense = 10, speed = 10, special = 10 } + +local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1431" + +local function tap(game, button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 6) +end + +local function openBattle(game, opts) + assert(game.world:startBattle(opts), "startBattle failed") + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + error("battle screen never came up") +end + +-- Stop on the frame the `.Shiny` arm is actually running: animId plus the +-- wBattleAnimParam the arm branches on (data/moves/animations.asm:414-417). +local function shinyArm(screen) + local anim = screen.anim + return anim ~= nil and anim.animId == "ANIM_SEND_OUT_MON" + and anim.param == 1 +end + +local function watch(game, screen, label, frames) + for _ = 1, (frames or 400) do + if shinyArm(screen) then + U.log("[driver] " .. label .. ": .Shiny arm running") + U.shot(game, ("%s/%s.png"):format(OUT, label)) + return true + end + U.wait(1) + end + U.log("[driver] " .. label .. ": NO shiny arm seen") + return false +end + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + -- A shiny only reads as one in colour, and the sparkle rides the same anim. + GbcPalette.setMode("gbc") + + local save = game.save + save.inventory = {} + local lead = Mon.new(game.data, "CYNDAQUIL", 40, { dvs = SHINY_DVS }) + local bench = Mon.new(game.data, "TOTODILE", 40, { dvs = SHINY_DVS }) + assert(lead.shiny and bench.shiny, "the FORCESHINY DVs did not take") + local move = assert(game.data.moves.EMBER, "no EMBER in moves.lua") + lead.moves = { { id = "EMBER", pp = move.pp, maxPp = move.pp } } + save.party = { lead, bench } + + local entry = game.world:trainerParty(36, 1) -- BUG_CATCHER member 1 + assert(entry, "no BUG_CATCHER member 1 in trainers.lua") + local Trainers = require("src.world.gen2.Trainers") + entry.party = Trainers.party(game.data, entry) + for _, mon in ipairs(entry.party) do mon.shiny = true end + local screen = openBattle(game, { trainer = entry }) + + ------------------------------------------------------------ enemy lead + -- ShowSetEnemyMonAndSendOutAnimation, out of the opening sequence. + watch(game, screen, "00-enemy-sendout", 900) + + ----------------------------------------------------------- player lead + -- SendOutPlayerMon behind the "Go!" line: this is the one the report is + -- about, and it never sparkled before the fix. + local sawPlayer = watch(game, screen, "01-player-sendout", 900) + + for _ = 1, 600 do + if screen.phase == "menu" then break end + tap(game, "a", 2) + end + + ---------------------------------------------------------- player switch + -- The same routine again, this time as a voluntary mid-battle switch. + local sawSwitch = false + if screen.phase == "menu" and (screen.battle.party or {})[2] then + screen:submit({ kind = "switch", index = 2 }) + sawSwitch = watch(game, screen, "02-player-switch", 900) + for _ = 1, 600 do + if screen.phase == "menu" or screen.battle.over then break end + tap(game, "a", 2) + end + end + + ------------------------------------------------------ enemy replacement + local sawReplace = false + if #(screen.battle.enemyParty or {}) > 1 then + for _ = 1, 600 do + if screen.battle.over then break end + if screen.phase == "menu" then + local enemy = screen.battle.enemy + if enemy then enemy.hp = 1 end + screen:chooseMenu("fight") + U.wait(2) + screen:chooseMove(1) + break + end + tap(game, "a", 2) + end + sawReplace = watch(game, screen, "03-enemy-replacement", 900) + end + + U.log(("[driver] player lead-off %s, player switch %s, enemy replacement %s") + :format(tostring(sawPlayer), tostring(sawSwitch), tostring(sawReplace))) + U.log("[driver] shots in " .. OUT) + U.log("[driver] every send-out of a shiny must flash, sparkle and chime") + U.log("[driver] BEFORE its cry, not only the wild mon at battle start.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/gold_bug1441_test.lua b/tests/drivers/gold_bug1441_test.lua new file mode 100644 index 00000000..7b4d9bbb --- /dev/null +++ b/tests/drivers/gold_bug1441_test.lua @@ -0,0 +1,72 @@ +-- #1441: the Magnet Train ride drew a flat white screen. +-- +-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bug1441_test.lua love . +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1441 (default) +-- +-- Nothing here is assertable: the bug IS what the screen shows. The ride +-- bakes its background out of data.gen2Field.magnetTrain, TILESET_TRAIN_ +-- STATION's sheet and the TOWN palettes, and every one of those was read +-- under its Gen 1 key, so all four came back nil and GbcPalette's fallback +-- filled the panel with DMG_SHADES[1]. +-- +-- The run boots into the Goldenrod station, prints whether the four tables +-- are actually there, shoots the Saffron-bound ride from JUMPTABLE_INIT +-- through to the arrival, then plays the Goldenrod-bound one back at 1x with +-- no screenshots in the way -- a human watching the window sees the whole +-- animation, which is the only place this bug ever showed. +local U = require("tests.drivers.util") + +local STATION = "GOLDENROD_MAGNET_TRAIN_STATION" + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1441" + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + world:setMap(STATION, 11, 8, "up") + U.wait(10) + + local data = game.data or {} + local field = data.gen2Field and data.gen2Field.magnetTrain + U.log("gen2Field.magnetTrain:", field and #field.bgTiles or "MISSING") + U.log("gen2Tilesets.TILESET_TRAIN_STATION:", + data.gen2Tilesets and data.gen2Tilesets.TILESET_TRAIN_STATION + and data.gen2Tilesets.TILESET_TRAIN_STATION.image or "MISSING") + U.log("gen2Palettes:", data.gen2Palettes and "ok" or "MISSING") + U.log("gen2Sprites.SPRITE_CHRIS:", + data.gen2Sprites and data.gen2Sprites.SPRITE_CHRIS and "ok" or "MISSING") + + local done = false + world:magnetTrain(true, function() done = true end) + U.wait(2) + + local ride = game.stack:top() + U.log("ride background:", ride and ride.background + and (ride:background() and "baked" or "nil canvas") or "no screen") + + -- SFX_TRAIN_ARRIVED lands a good while in; shoot across the whole ride so + -- the departure, the three scrolling bands and the stop are all on disk. + for index = 0, 11 do + U.shot(game, ("%s/%02d-ride.png"):format(out, index)) + U.wait(20) + if done then break end + end + for _ = 1, 600 do + if done then break end + U.wait(5) + end + U.log("ride finished at frame", U.frame()) + U.log("shots in " .. out) + + -- The return leg, played out in full with nothing else on screen: watch the + -- window, not the PNGs. The ride reads no input, so it ends on its own. + local back = false + world:magnetTrain(false, function() back = true end) + for _ = 1, 900 do + if back then break end + U.wait(1) + end + U.wait(60) +end diff --git a/tests/drivers/gold_radio_knob_bug1442.lua b/tests/drivers/gold_radio_knob_bug1442.lua new file mode 100644 index 00000000..4a47cbe2 --- /dev/null +++ b/tests/drivers/gold_radio_knob_bug1442.lua @@ -0,0 +1,57 @@ +-- Eyeball driver (#1442): the radio card's tuning knob. PokegearRadio_Init +-- spawns SPRITE_ANIM_OBJ_RADIO_TUNING_KNOB on tile $08 and AnimateTuningKnob +-- writes wRadioTuningKnob into its XOFFSET, so a red needle stands in the dial +-- box at screen x = 72 + knob and steps with every UP/DOWN. Before the fix the +-- card drew the dial art and nothing in it. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \ +-- POKEPORT_SHOTS=/tmp/radioknob \ +-- POKEPORT_DRIVER=tests/drivers/gold_radio_knob_bug1442.lua \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- The run parks on the radio card with the knob on 08.5, so UP and DOWN move +-- the needle by hand. +local U = require("tests.drivers.util") + +local SHOTS = os.getenv("POKEPORT_SHOTS") or "/tmp/radioknob" + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + -- Goldenrod, where the card is handed out, and the two engine flags the + -- START menu and the strip read: ENGINE_POKEGEAR and ENGINE_RADIO_CARD. + assert(world:setMap("GOLDENROD_CITY", 12, 20, "down"), + "setMap failed for GOLDENROD_CITY") + world:setEngineFlag(4, true) -- ENGINE_POKEGEAR + world:setEngineFlag(0, true) -- ENGINE_RADIO_CARD + U.wait(5) + + game:openStartMenuItem("pokegear") + U.wait(5) + local gear = game.stack:top() + assert(gear and gear.cards, "the POKeGEAR did not open") + gear.mode = "card" + for index, card in ipairs(gear.cards) do + if card.id == "radio" then gear.cardIndex = index end + end + assert(gear:card().id == "radio", "the RADIO card is missing from the strip") + U.wait(5) + + -- 04.5, the bottom of the dial: the needle sits at the left of the box. + U.shot(game, SHOTS .. "/01-radio-04.5.png") + U.log("knob", tostring(gear:currentStation().knob), "at 04.5") + + U.tap(game, "up") U.wait(4) + U.tap(game, "up") U.wait(4) + U.shot(game, SHOTS .. "/02-radio-08.5.png") + U.log("knob", tostring(gear:currentStation().knob), "at 08.5") + + U.log("compare 01 and 02: a red needle stands in the dial box and has", + "moved right; UP/DOWN now walk it by hand") + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/launcher_skins_tab_shot.lua b/tests/drivers/launcher_skins_tab_shot.lua new file mode 100644 index 00000000..6f0460b7 --- /dev/null +++ b/tests/drivers/launcher_skins_tab_shot.lua @@ -0,0 +1,100 @@ +-- Driver: renders the launcher's SKINS tab, exercises Use / import, and +-- shoots it. Stands in for main.lua's Importer branch so the panel can be +-- eyeballed without clicking through the real launcher. +-- SHOT_DIR=/tmp/skinstab POKEPORT_DRIVER=tests/drivers/launcher_skins_tab_shot.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local RomImporter = require("src.import.RomImporter") + local TouchSkin = require("src.core.TouchSkin") + + local dir = os.getenv("SHOT_DIR") or "/tmp/skinstab" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + love.window.setMode(1024, 768, { resizable = true, highdpi = true }) + U.wait(2) + + local studioOpenedWith = nil + local imp = RomImporter.new(function() end, { + launcher = true, + onOpenSkinStudio = function(v) studioOpenedWith = v end, + }) + + local pending = nil + love.draw = function() + imp:draw() + if pending then + local path = pending + pending = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then f:write(imagedata:encode("png"):getString()) f:close() end + end) + end + end + local function shot(name) + pending = dir .. "/" .. name + for _ = 1, 90 do + if not pending then break end + imp:update(1 / 60) + coroutine.yield() + end + U.wait(3) + local f = io.open(dir .. "/" .. name, "rb") + U.log(f and "shot" or "FAIL shot", name) + if f then f:close() end + end + + imp:_switchTab("skins") + U.wait(3) + U.log("tab:", imp.tab) + local skins = imp:_ensureSkins() + U.log("skins listed:", #skins) + for _, e in ipairs(skins) do + U.log((" %s source=%s pages=%d buttons=%d screen=%s ok=%s"):format( + e.id, e.source, e.pages, e.controls, tostring(e.screen), tostring(e.ok))) + end + U.log("active skin:", tostring(imp:_activeSkin())) + shot("skins_tab.png") + + imp._gamePopup = true + U.wait(2) + shot("game_dropdown.png") + imp._gamePopup = nil + U.wait(2) + + -- pick one, the way clicking its row does + imp:_useSkin("tv_crt") + U.wait(2) + U.log("after use:", tostring(imp:_activeSkin()), "notice:", imp._skinNotice.text) + shot("skins_tab_selected.png") + + -- studio hook carries a real cartridge, not the "skins" tab id + imp.modScope = "red" + if imp.onOpenSkinStudio then imp.onOpenSkinStudio(imp.modScope or "red") end + U.log("studio opened with version:", tostring(studioOpenedWith)) + + -- import: a dropped zip on this tab is a skin, not a mod + local exported = TouchSkin.export( + assert(TouchSkin.load("assets/skins/gb_anim", "gb_anim")), + "skins/dropped_probe.zip") + U.log("staged archive:", tostring(exported)) + local raw = love.filesystem.read("skins/dropped_probe.zip") + love.filesystem.remove("skins/dropped_probe.zip") + -- same shape LOVE's DroppedFile presents to readDroppedFile + local dropped = { + getFilename = function() return "/tmp/my_cool_skin.zip" end, + getSize = function() return #raw end, + open = function() return true end, + read = function() return raw end, + close = function() return true end, + } + imp:filedropped(dropped) + U.log("import notice:", imp._skinNotice.text) + U.log("skins after import:", #imp:_ensureSkins()) + U.log("my_cool_skin found:", tostring(TouchSkin.find("my_cool_skin") ~= nil)) + imp:_useSkin(nil) + shot("skins_tab_imported.png") + + U.log("done") + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/drivers/pikachu_entrance_bug1429_test.lua b/tests/drivers/pikachu_entrance_bug1429_test.lua new file mode 100644 index 00000000..02cd8410 --- /dev/null +++ b/tests/drivers/pikachu_entrance_bug1429_test.lua @@ -0,0 +1,89 @@ +-- Driver: the starter Pikachu's battle entrance (#1429). +-- +-- POKEPORT_DRIVER=tests/drivers/pikachu_entrance_bug1429_test.lua \ +-- POKEPORT_VERSION=yellow POKEPORT_IDENTITY=bug1429 POKEPORT_TOUCH=0 \ +-- SHOT_DIR=/tmp/shots love . (never under POKEPORT_SPEED: audio-timed) +-- +-- SendOutMon branches on IsThisPartyMonStarterPikachu (pokeyellow +-- engine/battle/core.asm:1798-1819): the starter never gets POOF_ANIM or +-- AnimateSendingOutMon. It walks in instead -- +-- StarterPikachuBattleEntranceAnimation (engine/battle/pikachu_entrance_anim.asm) +-- paints the back pic one column at a time from hlcoord 0,5, eight columns two +-- frames apart, and only then does PlayPikachuSoundClip voice it. +-- +-- The run halts in the middle of the walk-in, then again on the finished pic. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local GameVersion = require("src.core.GameVersion") + local PF = require("src.world.PikachuFollower") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + check("running as Yellow (needs POKEPORT_VERSION=yellow)", + GameVersion.isYellow()) + + game.save.player.name = "bryan" + local pika = Pokemon.new(game.data, "PIKACHU", 20) + BattleState.stampOT(game.save, pika) + game.save.party = { pika, Pokemon.new(game.data, "CHARMANDER", 20) } + check("the lead reads as the starter Pikachu", + PF.isStarterPikachu(game.save, pika)) + + U.teleport(game, "ROUTE_1", 5, 20, "down") + U.wait(20) + local ow = game.overworld + check("overworld is up to push the battle from", ow ~= nil) + + local battle = BattleState.newWild(game, "PIDGEY", 5) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function tapUntil(cond, taps, gap) + for _ = 1, (taps or 90) do + if cond() then return true end + U.tap(game, "a") + for _ = 1, (gap or 4) do + if cond() then return true end + U.wait(1) + end + end + return cond() + end + + -- Poll every frame: the slide is 16 frames long and the ball poof, if the + -- bug were back, would never set this slot at all. + local sliding = tapUntil(function() + return battle.picOff ~= nil and battle.picOff.playerMon ~= nil + end, 120, 2) + check("the send-out started the entrance slide, not the ball poof", sliding) + check("no grow-in is running alongside it", battle.growIn == nil) + + if sliding then + U.log("slide x =", tostring(battle.picOff.playerMon.x)) + check("mid-slide screenshot reached disk", + U.shot(game, DIR .. "/bug1429_pikachu_sliding.png")) + U.log("captured", DIR .. "/bug1429_pikachu_sliding.png") + for _ = 1, 40 do + if battle:picOffset("playerMon") == 0 then break end + U.wait(1) + end + check("the pic landed on its own column", battle:picOffset("playerMon") == 0) + check("landed screenshot reached disk", + U.shot(game, DIR .. "/bug1429_pikachu_landed.png")) + U.log("captured", DIR .. "/bug1429_pikachu_landed.png") + end + + U.log("Pikachu should have walked in from the LEFT edge of the screen,") + U.log("column by column, with no ball, no poof and no grow-out-of-the-ball,") + U.log("and voiced its PCM clip only once it was fully drawn (#1429).") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/shift_prompt_box_bug1398_test.lua b/tests/drivers/shift_prompt_box_bug1398_test.lua new file mode 100644 index 00000000..51472d78 --- /dev/null +++ b/tests/drivers/shift_prompt_box_bug1398_test.lua @@ -0,0 +1,114 @@ +-- Driver: where the "Will PLAYER change POKéMON?" YES/NO box sits (#1398). +-- +-- POKEPORT_DRIVER=tests/drivers/shift_prompt_box_bug1398_test.lua \ +-- POKEPORT_IDENTITY=bug1398 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- SHOT_DIR=/tmp/shots love . +-- +-- EnemySendOutFirstMon (engine/battle/core.asm:1378-1384) does NOT go through +-- InitYesNoTextBoxParameters for this one prompt: it inlines its own +-- TWO_OPTION_MENU at hlcoord 0,7, i.e. hard against the LEFT edge, while every +-- other YES/NO in the game sits at hlcoord 14,7 on the right. The port only +-- had the shared right-hand box, so the SHIFT offer came up on the wrong side. +-- +-- The run stops with the prompt on screen and the controls in a human's hands. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local Theme = require("src.ui.Theme") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- roster 1 is RATTATA 11 / EKANS 11: two mons, so the KO of the first one + -- reaches EnemySendOutFirstMon with a reserve to announce. + local OPP, ROSTER = "OPP_YOUNGSTER", 1 + + check("trainer class " .. OPP .. " is in the data", + game.data.trainers ~= nil and game.data.trainers[OPP] ~= nil) + check("Theme carries the left-edge box", + Theme.trainerSwitchBox ~= nil and Theme.trainerSwitchBox.tx == 0 + and Theme.trainerSwitchBox.ty == 7) + + -- SHIFT is what makes the prompt appear at all (the BIT_BATTLE_SHIFT test at + -- core.asm:1375-1377 skips it under SET). + game.save.options = game.save.options or {} + game.save.options.battleStyle = "shift" + game.save.player.name = "bryan" + game.save.party = { + Pokemon.new(game.data, "MEWTWO", 70), + Pokemon.new(game.data, "CHARIZARD", 50), + } + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(20) + local ow = game.overworld + check("overworld is up to push the battle from", ow ~= nil) + + local ok, battle = pcall(BattleState.newTrainer, game, OPP, ROSTER) + check("trainer battle constructed", ok and battle ~= nil) + if not ok then + U.log("could not start", OPP, "->", tostring(battle)) + while true do coroutine.yield() end + end + check("the foe has a reserve to send out", #battle.enemyParty >= 2) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function tapUntil(cond, taps, gap) + for _ = 1, (taps or 60) do + if cond() then return true end + U.tap(game, "a") + for _ = 1, (gap or 6) do + if cond() then return true end + U.wait(1) + end + end + return cond() + end + + if not check("reached the FIGHT/PKMN/ITEM/RUN menu", + tapUntil(function() return battle.phase == "menu" end, 200)) then + U.log("phase is", tostring(battle.phase)) + end + + -- FIGHT, first move: L70 MEWTWO one-shots the L11 lead. + U.tap(game, "a") + U.wait(10) + U.tap(game, "a") + U.wait(10) + + -- Stop the moment the YES/NO goes up over the still-visible prompt page. + local function choiceBox() + local top = game.stack:top() + return (top and top.tx and top.labels and top.labels[1] == "YES") and top + or nil + end + local reached = tapUntil(function() return choiceBox() ~= nil end, 200, 4) + if not check("the SHIFT prompt opened its YES/NO box", reached) then + U.log("phase", tostring(battle.phase), + "enemy hp", tostring(battle.enemy and battle.enemy.mon.hp), + "top", tostring(game.stack:top())) + end + + local box = choiceBox() + if box then + check("the box is at hlcoord 0,7 (core.asm:1378-1384)", + box.tx == 0 and box.ty == 7) + U.log("box tx=" .. tostring(box.tx) .. " ty=" .. tostring(box.ty)) + check("prompt screenshot reached disk", + U.shot(game, DIR .. "/bug1398_shift_prompt.png")) + U.log("captured", DIR .. "/bug1398_shift_prompt.png") + end + + U.log("The foe's lead is down and the SHIFT offer is on screen.") + U.log("YES/NO must sit against the LEFT edge, over the text box's left half,") + U.log("not on the right where every other YES/NO in the game lives (#1398).") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/skin_studio_author_tv.lua b/tests/drivers/skin_studio_author_tv.lua new file mode 100644 index 00000000..b95f3c4b --- /dev/null +++ b/tests/drivers/skin_studio_author_tv.lua @@ -0,0 +1,116 @@ +-- Driver: authors a CRT bezel skin from nothing using only Skin Studio +-- actions -- New, drop the art on the window, Detect screen, Save -- then +-- loads the saved folder back and renders the game through it. Answers +-- "could the studio have made tv_crt?" by doing it. +-- SHOT_DIR=/tmp/author POKEPORT_DRIVER=tests/drivers/skin_studio_author_tv.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Studio = require("src.ui.SkinStudio") + local TouchSkin = require("src.core.TouchSkin") + + local dir = os.getenv("SHOT_DIR") or "/tmp/author" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + love.window.setMode(1280, 720, { resizable = true, highdpi = true }) + U.wait(2) + + local baseDraw = love.draw + local pending = nil + love.draw = function() + Studio.draw() + if pending then + local path = pending + pending = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then f:write(imagedata:encode("png"):getString()) f:close() end + end) + end + end + local function shot(name) + pending = dir .. "/" .. name + for _ = 1, 90 do + if not pending then break end + coroutine.yield() + end + U.wait(3) + end + + -- a stand-in for LOVE's DroppedFile, same three calls Studio.filedropped uses + local function droppedFile(realPath) + local handle + return { + getFilename = function() return realPath end, + open = function() handle = assert(io.open(realPath, "rb")) end, + read = function() return handle:read("*a") end, + close = function() if handle then handle:close() handle = nil end end, + } + end + + Studio.load({ onClose = function() end }) + U.wait(3) + + -- 1. New skin, named + Studio.skin = TouchSkin.newSkin("my_tv") + Studio.skinIdField = "my_tv" + Studio.pageIndex, Studio.selected = 1, nil + Studio.images = {} + TouchSkin.setActive(Studio.skin) + U.log("1. new skin:", Studio.skin.id, "controls:", #Studio.page().controls) + + -- 2. Desktop canvas preset + for i, c in ipairs(Studio.CANVASES) do + if c.id == "desktop_1080" then Studio.setCanvas(i) end + end + U.log("2. canvas:", Studio.canvas().label, Studio.canvas().w .. "x" .. Studio.canvas().h) + + -- 3. Drop the bezel art on the window + Studio.filedropped(droppedFile("assets/skins/tv_crt/img/tv-integer.png")) + U.log("3. drop:", Studio.status) + U.log(" bezel:", tostring(Studio.page().imagePath), "root:", Studio.skin.root) + + -- 4. Detect the screen hole from the art's alpha + Studio.detectViewport() + local v = Studio.page().viewport + U.log("4. detect:", Studio.status) + U.log((" viewport %.4f %.4f %.4f %.4f"):format(v.x, v.y, v.w, v.h)) + shot("author_studio.png") + + -- 5. Save + Studio.save() + U.log("5. save:", Studio.status) + + -- what landed on disk + local root = "skins/my_tv" + for _, name in ipairs(love.filesystem.getDirectoryItems(root)) do + local info = love.filesystem.getInfo(root .. "/" .. name) + U.log(" file:", name, info and info.type) + end + local src = love.filesystem.read(root .. "/skin.lua") + U.log(" skin.lua bytes:", src and #src) + U.log(" skin.lua:", (src or ""):gsub("%s+", " "):sub(1, 260)) + + -- 6. Load the saved folder back as a normal skin and play through it + Studio.unload() + local reloaded, err = TouchSkin.load(root, "my_tv") + U.log("6. reload:", reloaded and "ok" or tostring(err), + "format:", reloaded and reloaded.format, + "controls:", reloaded and #reloaded.pages[1].controls) + + game.save.options.touchControls = { enabled = true, skin = "my_tv" } + game.save.options.tilt = 0 + game.save.options.zoom = 0 + game.save.options.pipelines = {} + game:applyOptions() + local TouchControls = require("src.core.TouchControls") + U.log(" active:", tostring(TouchControls.skinId), "err:", tostring(TouchControls.skinError)) + U.log(" decorativeOnly:", TouchSkin.decorativeOnly(), "drawable:", TouchSkin.drawable()) + + love.draw = baseDraw + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(12) + U.shot(game, dir .. "/author_result.png") + + U.log("done") + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/drivers/skin_studio_play_test.lua b/tests/drivers/skin_studio_play_test.lua new file mode 100644 index 00000000..7d9c971a --- /dev/null +++ b/tests/drivers/skin_studio_play_test.lua @@ -0,0 +1,57 @@ +-- Driver: presses Play in the Skin Studio the way the button does, then keeps +-- drawing and pumping update so the deferred handoff runs. Regression cover +-- for the crash where Play unloaded the studio inside its own draw pass. +-- POKEPORT_DRIVER=tests/drivers/skin_studio_play_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Studio = require("src.ui.SkinStudio") + + love.window.setMode(1280, 720, { resizable = true, highdpi = true }) + U.wait(2) + + local booted, bootVersion = false, nil + Studio.load({ + version = "red", + onClose = function() end, + onPlay = function(v) + -- what main.lua's handler does: unload, then boot + Studio.unload() + booted, bootVersion = true, v + end, + }) + U.wait(3) + U.log("loaded skin:", Studio.skin and Studio.skin.id) + + Studio.skinIdField = "play_probe" + + -- press Play exactly as the inspector button does + Studio.play() + U.log("after play(): pendingPlay =", tostring(Studio.pendingPlay), + "skin alive =", tostring(Studio.skin ~= nil), "booted =", tostring(booted)) + U.log("status:", tostring(Studio.status)) + + -- the frame that queued the handoff must still draw + local okDraw, errDraw = pcall(Studio.draw) + U.log("draw on the click frame:", okDraw, errDraw or "") + + -- now the deferred handoff + local okUp, errUp = pcall(Studio.update, 1 / 60) + U.log("update handoff:", okUp, errUp or "") + U.log("booted =", tostring(booted), "version =", tostring(bootVersion), + "skin =", tostring(Studio.skin)) + + -- and a draw after teardown, which is what actually crashed before + local okAfter, errAfter = pcall(Studio.draw) + U.log("draw after teardown:", okAfter, errAfter or "") + + local okUp2 = pcall(Studio.update, 1 / 60) + U.log("second update:", okUp2, "booted still once =", tostring(booted)) + + if okDraw and okUp and okAfter and booted then + U.log("RESULT pass") + else + U.log("RESULT FAIL") + end + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/drivers/skin_studio_shot.lua b/tests/drivers/skin_studio_shot.lua new file mode 100644 index 00000000..75d8bffb --- /dev/null +++ b/tests/drivers/skin_studio_shot.lua @@ -0,0 +1,107 @@ +-- Driver: shoots the desktop Skin Studio (#1386) across its canvas presets +-- and in Test mode. The studio is a top-level love.draw branch in main.lua, +-- so this stands in for that branch and captures the frames itself. +-- SHOT_DIR=/tmp/studio POKEPORT_DRIVER=tests/drivers/skin_studio_shot.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Studio = require("src.ui.SkinStudio") + local TouchControls = require("src.core.TouchControls") + + local dir = os.getenv("SHOT_DIR") or "/tmp/studio" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + love.window.setMode(1440, 900, { resizable = true, highdpi = true }) + U.wait(2) + + local pending = nil + love.draw = function() + Studio.draw() + if pending then + local path = pending + pending = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then f:write(imagedata:encode("png"):getString()) f:close() end + end) + end + end + + local function shot(name) + pending = dir .. "/" .. name + for _ = 1, 90 do + if not pending then break end + coroutine.yield() + end + U.wait(3) + local f = io.open(dir .. "/" .. name, "rb") + if f then f:close() U.log("shot", name) else U.log("FAIL shot", name) end + end + + Studio.load({ skinId = "gb_anim", onClose = function() end }) + U.wait(5) + U.log("skin:", Studio.skin and Studio.skin.id, "pages:", #(Studio.skin.pages or {})) + U.log("controls:", #Studio.page().controls, "images:", #(Studio.images or {})) + shot("studio_open.png") + + Studio.selected = 9 + U.wait(3) + local ctl = Studio.selectedControl() + U.log("selected:", ctl and ctl.spec, "x", ctl and ctl.x, "y", ctl and ctl.y) + shot("studio_selected.png") + + -- drag the selected control and confirm the model moved + local r = Studio.lastCanvas + if r and ctl then + local before = ctl.x + Studio.beginCanvasDrag(r.x + ctl.x * r.w, r.y + ctl.y * r.h, r) + Studio.updateDrag(r.x + ctl.x * r.w - 60, r.y + ctl.y * r.h, r) + Studio.drag = nil + U.log("drag moved x:", before, "->", ctl.x) + end + U.wait(3) + shot("studio_dragged.png") + + -- numeric entry: type an exact pixel X + if ctl then + Studio.commitField("numX", "100") + U.log("after numX=100 px, x fraction:", ctl.x, "rangeX:", ctl.rangeX) + end + + Studio.testing = true + TouchControls:setPreview(false) + U.wait(3) + if r then + local page = Studio.page() + local TouchSkin = require("src.core.TouchSkin") + TouchSkin.setSurface(r.x, r.y, r.w, r.h) + local cx, cy = TouchSkin.controlGeometry(page, page.controls[13], r.w, r.h, r.x, r.y) + Studio.mousepressed(cx, cy, 1) + TouchSkin.setSurface(nil) + local held = {} + for btn in pairs(TouchControls.held or {}) do held[#held + 1] = btn end + U.log("test press held:", table.concat(held, ",")) + end + U.wait(3) + shot("studio_test.png") + Studio.mousereleased(0, 0, 1) + Studio.testing = false + TouchControls:setPreview(true) + + -- Super Game Boy border preset: viewport locks to the 160x144 window + for i, c in ipairs(Studio.CANVASES) do + if c.id == "sgb_border" then Studio.setCanvas(i) end + end + U.wait(3) + local vp = Studio.page().viewport + U.log("sgb viewport:", vp.x, vp.y, vp.w, vp.h) + shot("studio_sgb.png") + + for i, c in ipairs(Studio.CANVASES) do + if c.id == "ultrawide" then Studio.setCanvas(i) end + end + U.wait(3) + shot("studio_ultrawide.png") + + U.log("done") + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/drivers/talk_pose_bug1435_test.lua b/tests/drivers/talk_pose_bug1435_test.lua new file mode 100644 index 00000000..8b63163f --- /dev/null +++ b/tests/drivers/talk_pose_bug1435_test.lua @@ -0,0 +1,73 @@ +-- Driver: nobody is caught mid-stride while a text box is up (#1435). +-- UpdatePlayerSprite and UpdateNPCSprite both jump to their standing frame +-- while BIT_FONT_LOADED is set (engine/overworld/movement.asm:57, :139), so a +-- box that opens on a walk frame freezes a standing sprite, never a leg out. +-- +-- POKEPORT_DRIVER=tests/drivers/talk_pose_bug1435_test.lua \ +-- POKEPORT_IDENTITY=bug1435 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- Mom stands at (5,4) in Red's house (pokered + -- data/maps/objects/RedsHouse1F.asm); park the player beside her and push + -- into her so the walk-in-place cycle is running when the box opens. + U.teleport(game, "REDS_HOUSE_1F", 6, 4, "down") + U.wait(20) + local ow = game.overworld + local p = ow.player + + local walking = false + for _ = 1, 90 do + table.insert(game.input.pressQueue, "left") + game.input.state.left = true + coroutine.yield() + if p:walkPhase() == 1 then walking = true break end + end + game.input.state.left = false + check("bumping into MOM shows the walk frame", walking) + U.shot(game, DIR .. "/bug1435_1_midstride.png") + + U.tap(game, "a") + U.wait(4) + check("talking to MOM opened a box over the overworld", + game.stack:top() ~= ow) + check("the walk clock is still mid-cycle underneath", + (p.bumpFrames or 0) > 0 or p.moving) + check("but the player is drawn standing", p:walkPhase() == 0) + U.shot(game, DIR .. "/bug1435_2_talking.png") + + -- The same gate for an NPC caught mid-step: Pallet Town's wanderers walk + -- on their own, so wait for one and open a box while it is between cells. + U.teleport(game, "PALLET_TOWN", 8, 8, "down") + U.wait(30) + ow = game.overworld + local mover + for _ = 1, 900 do + for _, npc in ipairs(ow.npcs) do + if npc.moving and npc:walkPhase() == 1 then mover = npc break end + end + if mover then break end + coroutine.yield() + end + if check("caught a Pallet Town NPC mid-step", mover ~= nil) then + game.stack:push(TextBox.new(game, "TESTING THE POSE.")) + U.wait(4) + check("the NPC is still mid-step underneath", mover.moving) + check("but it is drawn standing too", mover:walkPhase() == 0) + U.shot(game, DIR .. "/bug1435_3_npc.png") + end + + U.log("Look at bug1435_2_talking.png and bug1435_3_npc.png: with the box") + U.log("up, both sprites stand square on their feet. A leg out, or the") + U.log("split-stride frame held for the whole conversation, is the bug.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/title_seam_bug1453_test.lua b/tests/drivers/title_seam_bug1453_test.lua new file mode 100644 index 00000000..e0f42918 --- /dev/null +++ b/tests/drivers/title_seam_bug1453_test.lua @@ -0,0 +1,176 @@ +-- Manual check for #1453: the SGB zone scissors must stay contiguous in +-- framebuffer pixels on a fractional-DPI surface (Android), where LOVE 11 +-- truncates the scissor to whole units before scaling it. Zones are pokered +-- data/sgb/sgb_packets.asm BlkPacket_Titlescreen (rows 0-7 / 8-9 / 10-17). +-- POKEPORT_DRIVER=tests/drivers/title_seam_bug1453_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +-- PROBE_DPI picks the density to emulate (default 2.625, the reporter's). +-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music. +return function(game) + local U = dofile("tests/drivers/util.lua") + local PaletteFX = require("src.render.PaletteFX") + local Renderer = require("src.render.Renderer") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local DPI = tonumber(os.getenv("PROBE_DPI") or "") or 2.625 + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- past the copyright splash / attract movie (engine/movie/splash.asm) + local title + for _ = 1, 120 do + local top = game.stack:top() + if top and top.screenId == "TitleState" and top.sgbPalettes then + title = top + break + end + U.tap(game, "start") + U.wait(9) + end + check("the title screen is on top", title ~= nil) + if not (title and title.sgbPalettes) then + U.log("No title state to look at; nothing below can run.") + while true do coroutine.yield() end + end + + local opts = game.save.options + local mode = opts and opts.colors or PaletteFX.mode + if PaletteFX.mode ~= "gbc" then + U.log("COLORS is", tostring(mode) .. "; switching the view to SGB for the check") + PaletteFX.setMode("gbc") + U.wait(20) + end + local zones = PaletteFX.ensureZones(title:sgbPalettes(game)) + check("the title builds three SGB zones", zones ~= nil and #zones == 3) + + -- A canvas carries its own dpiscale, so the emulated Android surface goes + -- through the same LOVE scissor path the phone does, on this desktop. + local probe = love.graphics.newCanvas(math.floor(1920 / DPI), + math.floor(1080 / DPI), + { dpiscale = DPI }) + local PW, PH = probe:getPixelWidth(), probe:getPixelHeight() + local Sp = math.max(1, math.floor(math.min(PW / 160, PH / 144))) + local Ux, Uy = Sp / DPI, Sp / DPI + local uox = math.floor((PW - 160 * Sp) / 2) / DPI + local uoy = math.floor((PH - 144 * Sp) / 2) / DPI + local uvpw, uvph = 160 * Ux, 144 * Uy + U.log(("emulating %dx%d px at dpi %.4f, fit scale %d"):format(PW, PH, DPI, Sp)) + + -- what the pre-fix Renderer handed LOVE: fractional units with a half + -- framebuffer pixel of bias, which LOVE 11 truncates away + local function scissorOld(x, y, w, h) + local x2, y2 = math.min(x + w, uox + uvpw), math.min(y + h, uoy + uvph) + x, y = math.max(x, uox), math.max(y, uoy) + if x2 <= x or y2 <= y then return false end + local px1, py1 = math.floor(x * DPI), math.floor(y * DPI) + local px2, py2 = math.ceil(x2 * DPI), math.ceil(y2 * DPI) + love.graphics.setScissor((px1 + 0.5) / DPI, (py1 + 0.5) / DPI, + (px2 - px1 + 0.5) / DPI, (py2 - py1 + 0.5) / DPI) + return true + end + + local function renderProbe(old) + love.graphics.setCanvas(probe) + love.graphics.clear(0, 0, 0, 1) + love.graphics.setColor(1, 1, 1, 1) + if old then + local shader = PaletteFX.shader() + love.graphics.setShader(shader) + for _, z in ipairs(zones) do + PaletteFX.sendColors(shader, z.colors) + if scissorOld(uox + z.x * Ux, uoy + z.y * Uy, z.w * Ux, z.h * Uy) then + love.graphics.draw(Renderer.canvas, uox, uoy, 0, Ux, Uy) + end + end + love.graphics.setScissor() + love.graphics.setShader() + else + Renderer:blitCanvas(Renderer.canvas, Ux, Uy, zones, Ux, Uy, + uox, uoy, uox, uoy, uvpw, uvph, DPI, DPI) + end + love.graphics.setCanvas() + return probe:newImageData() + end + + local top = math.floor(uoy * DPI) + local bottom = math.floor((uoy + uvph) * DPI) + local col = math.floor(uox * DPI) + 2 + local function blackRows(data) + local rows = {} + for py = top, math.min(bottom - 1, data:getHeight() - 1) do + local r, g, b = data:getPixel(col, py) + if r < 0.02 and g < 0.02 and b < 0.02 then rows[#rows + 1] = py end + end + return rows + end + + local dataNew = renderProbe(false) + local dataOld = renderProbe(true) + local rowsNew, rowsOld = blackRows(dataNew), blackRows(dataOld) + U.log(("picture rows %d-%d, sampling the background column at x=%d") + :format(top, bottom - 1, col)) + U.log("pre-fix math left", #rowsOld, "black rows:", + #rowsOld > 0 and table.concat(rowsOld, ",") or "none") + check("no letterbox row shows through the zone boundaries", #rowsNew == 0) + check("the emulated surface reproduces the seam without the fix", #rowsOld > 0) + + local ZOOM, CW, CH = 4, 220, 28 + local cy = math.max(top, math.floor((uoy + 64 * Uy) * DPI) - math.floor(CH / 2)) + local cx = math.floor(uox * DPI) + local function zoomOf(data) + local out = love.image.newImageData(CW * ZOOM, CH * ZOOM) + out:mapPixel(function(x, y) + return data:getPixel(cx + math.floor(x / ZOOM), cy + math.floor(y / ZOOM)) + end) + return out + end + local function writePng(imageData, path) + local dir = path:match("^(.*)[/\\][^/\\]+$") + if dir and dir ~= "" then os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') end + local f = io.open(path, "wb") + if not f then return false end + f:write(imageData:encode("png"):getString()) + f:close() + return true + end + + local zoomOld, zoomNew = zoomOf(dataOld), zoomOf(dataNew) + check("wrote " .. SHOT_DIR .. "/bug1453_before.png", + writePng(zoomOld, SHOT_DIR .. "/bug1453_before.png")) + check("wrote " .. SHOT_DIR .. "/bug1453_after.png", + writePng(zoomNew, SHOT_DIR .. "/bug1453_after.png")) + + local imgOld = love.graphics.newImage(zoomOld) + local imgNew = love.graphics.newImage(zoomNew) + local baseDraw = love.draw + love.draw = function() + baseDraw() + local w, h = love.graphics.getDimensions() + local s = math.min((w - 24) / imgOld:getWidth(), + (h * 0.5 - 48) / (imgOld:getHeight() * 2)) + local bw = imgOld:getWidth() * s + 16 + local bh = imgOld:getHeight() * 2 * s + 56 + local bx, by = (w - bw) / 2, h - bh - 8 + love.graphics.setColor(0, 0, 0, 0.75) + love.graphics.rectangle("fill", bx, by, bw, bh) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.print("BEFORE (dpi " .. DPI .. ")", bx + 8, by + 4) + love.graphics.draw(imgOld, bx + 8, by + 20, 0, s, s) + love.graphics.print("AFTER", bx + 8, by + 24 + imgOld:getHeight() * s) + love.graphics.draw(imgNew, bx + 8, by + 40 + imgOld:getHeight() * s, 0, s, s) + end + + PaletteFX.setMode(mode) + U.wait(10) + U.shot(game, SHOT_DIR .. "/bug1453_title.png") + U.log("captured", SHOT_DIR .. "/bug1453_title.png") + U.log("The title is live and the pad is yours; the strip along the bottom is") + U.log("the row-64 zone boundary of an emulated " .. DPI .. "x Android surface,") + U.log("magnified " .. ZOOM .. "x: BEFORE carries the black hairline across the") + U.log("picture, AFTER is unbroken off-white. PROBE_DPI=2.75 tries another one.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/touch_skin_editor_shot.lua b/tests/drivers/touch_skin_editor_shot.lua new file mode 100644 index 00000000..18947638 --- /dev/null +++ b/tests/drivers/touch_skin_editor_shot.lua @@ -0,0 +1,59 @@ +-- Driver: shoots the launcher's Touch Controls editor with the built-in pad +-- and with the bundled skin picked, so the Skin row (#1386) can be eyeballed. +-- The editor is a top-level love.draw branch in main.lua, not a stack state, +-- so this stands in for that branch and captures the frame itself. +-- SHOT_DIR=/tmp/skin POKEPORT_DRIVER=tests/drivers/touch_skin_editor_shot.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Editor = require("src.ui.TouchControlsEditor") + local TouchControls = require("src.core.TouchControls") + + local dir = os.getenv("SHOT_DIR") or "/tmp/skin" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + love.window.setMode(432, 768, { resizable = true, highdpi = true }) + U.wait(2) + + local pending = nil + love.draw = function() + Editor.draw() + if pending then + local path = pending + pending = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then f:write(imagedata:encode("png"):getString()) f:close() end + end) + end + end + + local function shot(name) + pending = dir .. "/" .. name + for _ = 1, 60 do + if not pending then break end + coroutine.yield() + end + U.wait(3) + local f = io.open(dir .. "/" .. name, "rb") + if f then f:close() U.log("shot", name) else U.log("FAIL shot", name) end + end + + Editor.load({ onClose = function() end }) + U.wait(5) + U.log("skins:", #Editor.skins, "label:", Editor.skinLabel()) + shot("editor_pad.png") + + Editor.cycleSkin(1) + U.wait(5) + U.log("picked:", tostring(TouchControls.skinId), Editor.skinLabel()) + shot("editor_skin.png") + + Editor.exportSkin() + U.wait(5) + U.log("export:", tostring(Editor.exportMsg)) + U.log("skins after export:", #Editor.skins) + shot("editor_export.png") + + U.log("done") + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/drivers/touch_skin_shot.lua b/tests/drivers/touch_skin_shot.lua new file mode 100644 index 00000000..c45ad950 --- /dev/null +++ b/tests/drivers/touch_skin_shot.lua @@ -0,0 +1,71 @@ +-- Driver: shoots the bundled RetroArch-format touch skin (assets/skins/gb_anim) +-- over a live overworld in a portrait window, idle and with A/DOWN held. +-- SHOT_DIR=/tmp/skin POKEPORT_TOUCH=1 POKEPORT_DRIVER=tests/drivers/touch_skin_shot.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local TouchControls = require("src.core.TouchControls") + local TouchSkin = require("src.core.TouchSkin") + local Pokemon = require("src.pokemon.Pokemon") + + local dir = os.getenv("SHOT_DIR") or "/tmp/skin" + local skinId = os.getenv("SKIN") or "gb_anim" + + love.window.setMode(432, 768, { resizable = true, highdpi = true }) + U.wait(2) + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.player.name = "bryan" + if os.getenv("SKIN") == "none" then skinId = nil end + game.save.options.touchControls = { enabled = true, skin = skinId } + game.save.options.tilt = 0 + game.save.options.zoom = 0 + game.save.options.pipelines = {} + game:applyOptions() + + U.log("skins on disk:") + for _, entry in ipairs(TouchSkin.list()) do + U.log(" ", entry.id, entry.source, entry.root) + end + U.log("active:", tostring(TouchControls.skinId), "err:", tostring(TouchControls.skinError)) + local page = TouchSkin.page() + U.log("page:", page and page.name, "controls:", page and #page.controls) + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + local R = game.renderer + local pw, ph = love.graphics.getPixelDimensions() + U.log("pixel dims:", pw, ph) + U.log("skin viewport:", TouchSkin.viewport(pw, ph)) + U.log("hasViewport:", TouchSkin.hasViewport(), "fitScale:", R:fitScale()) + U.log("worldViewSize:", R:worldViewSize()) + U.log("worldOverride:", tostring(R.worldOverride), "worldActive:", tostring(R.worldActive)) + U.shot(game, dir .. "/skin_idle.png") + + local ww, wh = love.graphics.getDimensions() + local function at(nx, ny) return nx * ww, ny * wh end + + TouchControls:touchpressed("t1", at(0.87407, 0.72417)) + TouchControls:touchpressed("t2", at(0.24074, 0.79771)) + U.wait(4) + U.log("held:", (function() + local out = {} + for btn in pairs(TouchControls.held or {}) do out[#out + 1] = btn end + table.sort(out) + return table.concat(out, ",") + end)()) + U.shot(game, dir .. "/skin_pressed.png") + TouchControls:touchreleased("t1", at(0.87407, 0.72417)) + TouchControls:touchreleased("t2", at(0.24074, 0.79771)) + U.wait(4) + + TouchControls:touchpressed("t3", at(0.95, 0.528)) + TouchControls:touchreleased("t3", at(0.95, 0.528)) + U.wait(6) + U.log("page after overlay_next:", TouchSkin.page() and TouchSkin.page().name) + U.shot(game, dir .. "/skin_page2.png") + + U.log("done") + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/drivers/town_map_marker_bug1437_test.lua b/tests/drivers/town_map_marker_bug1437_test.lua new file mode 100644 index 00000000..08cb51a0 --- /dev/null +++ b/tests/drivers/town_map_marker_bug1437_test.lua @@ -0,0 +1,72 @@ +-- Driver: the TOWN MAP player marker wears the OBJ palette (#1437). +-- LoadPlayerSpriteGraphics hands the town map the same walking sheet the +-- overworld draws (engine/items/town_map.asm:342), so the marker has to go +-- through the OBJ ramp and the shade-0 keying every other sprite gets -- +-- green on Red, pink on Blue, never a raw white block on the BG ramp. +-- +-- POKEPORT_DRIVER=tests/drivers/town_map_marker_bug1437_test.lua \ +-- POKEPORT_IDENTITY=bug1437 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Screens = require("src.ui.Screens") + local PaletteFX = require("src.render.PaletteFX") + local SpriteRenderer = require("src.render.SpriteRenderer") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + game.save.player.name = "bryan" + game.save.visited = { + PALLET_TOWN = true, VIRIDIAN_CITY = true, PEWTER_CITY = true, + CERULEAN_CITY = true, CELADON_CITY = true, + } + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + local function openMap(mode, tag) + game.save.options = game.save.options or {} + game.save.options.colors = mode + PaletteFX.setMode(mode) + U.wait(4) + Screens.push(game, "TownMap") + U.wait(20) + local top = game.stack:top() + check(tag .. ": the TOWN MAP is up", top and top.playerQuad ~= nil) + -- the marker must be a baked OBJ image, not a fresh newImage of the sheet + local sprites = game.data.sprites or {} + local red = sprites.SPRITE_RED + local colors, group + if PaletteFX.usesSpriteObp() then + colors, group = PaletteFX.ogObj() + else + colors, group = PaletteFX.dmgObj() + end + local want = red and SpriteRenderer.obpImage(red.image, colors, group) + check(tag .. ": the marker is the OBP-baked sheet", + top and want ~= nil and top.playerSheet == want) + -- hold the shot on a frame the blink is showing the marker + for _ = 1, 40 do + if top.blink < 16 then break end + coroutine.yield() + end + U.shot(game, DIR .. "/bug1437_" .. tag .. ".png") + U.tap(game, "b") + U.wait(10) + end + + openMap("ogred", "ogred") + openMap("gbc", "gbc") + openMap("og", "og") + + U.log("Open the shots: in OG RED the marker over PALLET TOWN is the green") + U.log("boot-ROM trainer, in the other modes it is coloured by the map zone") + U.log("like any overworld sprite. A white box, or a red/black marker on") + U.log("the BG ramp, is the bug. The pad is yours -- the TOWN MAP is one") + U.log("B away and the ITEM bag reopens it.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/tv_skin_shot.lua b/tests/drivers/tv_skin_shot.lua new file mode 100644 index 00000000..3d701049 --- /dev/null +++ b/tests/drivers/tv_skin_shot.lua @@ -0,0 +1,53 @@ +-- Driver: shoots the bundled desktop CRT bezel (assets/skins/tv_crt) over a +-- live overworld in a 16:9 window, and checks the alpha-based screen detector +-- against the viewport the .cfg declares. +-- SHOT_DIR=/tmp/tv POKEPORT_DRIVER=tests/drivers/tv_skin_shot.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local TouchControls = require("src.core.TouchControls") + local TouchSkin = require("src.core.TouchSkin") + local Pokemon = require("src.pokemon.Pokemon") + + local dir = os.getenv("SHOT_DIR") or "/tmp/tv" + local skinId = os.getenv("SKIN") or "tv_crt" + + love.window.setMode(1280, 720, { resizable = true, highdpi = true }) + U.wait(2) + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.player.name = "bryan" + game.save.options.touchControls = { enabled = true, skin = skinId } + game.save.options.tilt = 0 + game.save.options.zoom = 0 + game.save.options.pipelines = {} + game:applyOptions() + + U.log("active:", tostring(TouchControls.skinId), "err:", tostring(TouchControls.skinError)) + local page = TouchSkin.page() + U.log("page:", page and page.name, "controls:", page and #page.controls) + local v = page and page.viewport + U.log("declared viewport:", v and v.x, v and v.y, v and v.w, v and v.h) + + local detected = TouchSkin.detectViewport(TouchSkin.active.root, page.imagePath) + if detected then + U.log(("detected hole: %.4f %.4f %.4f %.4f"):format( + detected.x, detected.y, detected.w, detected.h)) + U.log(("delta vs declared: %.4f %.4f %.4f %.4f"):format( + detected.x - v.x, detected.y - v.y, detected.w - v.w, detected.h - v.h)) + else + U.log("FAIL no hole detected") + end + + local pw, ph = love.graphics.getPixelDimensions() + U.log("pixel dims:", pw, ph) + U.log("engine viewport px:", TouchSkin.viewport(pw, ph)) + U.log("fitScale:", game.renderer:fitScale()) + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(12) + U.shot(game, dir .. "/tv_overworld.png") + + U.log("done") + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/engine/exp_traded_ot_survives_reload_bug1265.lua b/tests/engine/exp_traded_ot_survives_reload_bug1265.lua index 26619855..8cdd3917 100644 --- a/tests/engine/exp_traded_ot_survives_reload_bug1265.lua +++ b/tests/engine/exp_traded_ot_survives_reload_bug1265.lua @@ -46,4 +46,30 @@ do T.eq(tradedWithId.otId, 777, "a recorded foreign OT id is never overwritten") end +-- #1461: saves that passed through 0.1.82-0.1.9x already have the player's +-- own id written onto traded mons, so stamping correctly from now on does +-- not help them. The repair runs on every load, not behind a format gate. +do + local SaveData = require("src.core.SaveData") + local save = newSave() + local poisoned = { traded = true, otId = 12345 } + local foreign = { traded = true, otId = 777 } + local caught = { otId = 12345 } + save.party = { poisoned, foreign, caught } + save.boxes = { { { traded = true, otId = 12345 } } } + + T.eq(SaveData.repairTradedOtIds(save), 2, "both poisoned mons are repaired") + T.eq(poisoned.otId, nil, "a traded mon carrying the player id is cleared") + T.eq(save.boxes[1][1].otId, nil, "boxed mons are repaired too") + T.eq(foreign.otId, 777, "a real foreign OT id is left alone") + T.eq(caught.otId, 12345, "a caught mon keeps the player id") + T.eq(caught.traded, nil, "and is never marked traded") + + T.eq(SaveData.repairTradedOtIds(save), 0, "the repair is idempotent") + + -- and after the repair the stamp loop must not re-adopt it + BattleState.stampOT(save, poisoned) + T.eq(poisoned.otId, nil, "the stamp loop does not undo the repair") +end + T.finish("exp traded ot survives reload bug 1265") diff --git a/tests/engine/gen2_battler_gfx_rows_bug1231.lua b/tests/engine/gen2_battler_gfx_rows_bug1231.lua index e74a036a..4a811e0c 100644 --- a/tests/engine/gen2_battler_gfx_rows_bug1231.lua +++ b/tests/engine/gen2_battler_gfx_rows_bug1231.lua @@ -47,12 +47,32 @@ do T.eq(feet.tile, (0x80 - 6 * 2) - 49, "2ROW base") end +-- engine/battle_anims/anim_commands.asm:317 do local runner = AnimRunner.new({}) runner:start(nil) AnimRunner.COMMANDS.battlergfx_1row(runner) local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD") + local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET") T.eq(head and head.battler, "enemy", "the script command routes the same way") + T.eq(head.rows, 2, "$da's macro says 1row, its jumptable slot says _2Row") + T.eq(head.tiles, 14, "two enemy rows") + T.eq(head.tile, (0x80 - 6 * 2 - 7 * 2) - 49, "at the _2Row base") + T.eq(feet.tiles, 12, "two player rows") + T.eq(feet.tile, (0x80 - 6 * 2) - 49, "at the _2Row base") +end + +do + local runner = AnimRunner.new({}) + runner:start(nil) + AnimRunner.COMMANDS.battlergfx_2row(runner) + local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD") + local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET") + T.eq(head.rows, 1, "$d9's macro says 2row, its jumptable slot says _1Row") + T.eq(head.tiles, 7, "one enemy row") + T.eq(head.tile, (0x80 - 6 - 7) - 49, "at the _1Row base") + T.eq(feet.tiles, 6, "one player row") + T.eq(feet.tile, (0x80 - 6) - 49, "at the _1Row base") end T.finish("gen2 battler gfx row attribution bug 1231") diff --git a/tests/engine/gen2_status_type_immunity_bug1444.lua b/tests/engine/gen2_status_type_immunity_bug1444.lua new file mode 100644 index 00000000..6fff9649 --- /dev/null +++ b/tests/engine/gen2_status_type_immunity_bug1444.lua @@ -0,0 +1,62 @@ +-- engine/battle/effect_commands.asm:5788, :3671, :3646, :4019 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local Battle = require("src.battle.gen2.Battle") + +local MATCHUPS = { + { attacker = "ELECTRIC", defender = "GROUND", multiplier = 0 }, + { attacker = "POISON", defender = "STEEL", multiplier = 0 }, + { attacker = "POISON", defender = "POISON", multiplier = 5 }, + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + { attacker = "PSYCHIC_TYPE", defender = "GROUND", multiplier = 10 }, +} + +local POKEMON = { + GEODUDE = { types = { "ROCK", "GROUND" } }, + MAGNEMITE = { types = { "STEEL", "ELECTRIC" } }, + ZUBAT = { types = { "POISON", "FLYING" } }, + GASTLY = { types = { "GHOST", "POISON" } }, + PIDGEY = { types = { "NORMAL", "FLYING" } }, +} + +local battle = setmetatable({ + data = { type_chart = { matchups = MATCHUPS }, pokemon = POKEMON }, +}, { __index = Battle }) + +local function mon(species) return { species = species, hp = 20 } end +local function refused(species, moveType, status) + return battle:statusRefusedByType(mon(species), moveType, status) +end + +-- The two repros the reporter filed, neither of which the #1318 fix touched. +T.eq(refused("GEODUDE", "ELECTRIC", "paralyze"), true, + "Thunder Wave does not affect a GROUND type") +T.eq(refused("MAGNEMITE", "POISON", "poison"), true, + "Poison Gas does not affect a STEEL type") + +-- Poison vs POISON is 0.5x, not an immunity, so only CheckIfTargetIsPoisonType +-- refuses it: this is the Poison Sting on Zubat case. +T.eq(refused("ZUBAT", "POISON", "poison"), true, + "a POISON type cannot be poisoned even on a resisted, landed hit") +T.eq(refused("GASTLY", "POISON", "toxic"), true, + "Toxic is refused by the same check") +T.eq(refused("GASTLY", "NORMAL", "paralyze"), true, + "Glare does not affect a GHOST type") + +-- Everything the cart deliberately leaves ungated stays ungated. +T.eq(refused("GEODUDE", "PSYCHIC_TYPE", "sleep"), false, + "Hypnosis still lands on a GROUND type") +T.eq(refused("GEODUDE", "PSYCHIC_TYPE", "confuse"), false, + "confusion is never type-gated") +T.eq(refused("PIDGEY", "ELECTRIC", "paralyze"), false, + "Thunder Wave still paralyses a non-immune target") +T.eq(refused("PIDGEY", "POISON", "poison"), false, + "a non-POISON target is still poisonable") +T.eq(refused("GEODUDE", nil, "paralyze"), false, + "a status with no move type behind it is not refused") + +T.finish("gen2 status type immunity bug 1444") diff --git a/tests/engine/launcher_skins_tab.lua b/tests/engine/launcher_skins_tab.lua new file mode 100644 index 00000000..3c69039e --- /dev/null +++ b/tests/engine/launcher_skins_tab.lua @@ -0,0 +1,86 @@ +-- Launcher SKINS tab (#1386): the tab next to Find that lists skins, imports +-- a dropped .zip and opens the desktop Skin Studio. The rendered panel is +-- exercised by tests/drivers/launcher_skins_tab_shot.lua; this pins the +-- archive installer and the launcher wiring around it. +-- luajit tests/engine/launcher_skins_tab.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") + +local function read(path) + local f = assert(io.open(path, "r")) + local src = f:read("*a") + f:close() + return src +end + +-- ------------------------------------------------------ archive installer + +eq(select(1, TouchSkin.installArchive("skin.zip", nil)), nil, + "an empty archive is refused") +eq(select(1, TouchSkin.installArchive("skin.zip", "")), nil, + "a zero-byte archive is refused") +eq(select(1, TouchSkin.installArchive("notes.txt", "data")), nil, + "a non-zip is refused") +eq(select(1, TouchSkin.installArchive("", "data")), nil, + "a nameless drop is refused") + +-- A zip carrying no skin file must not leave a stray archive behind: the +-- list would keep trying to mount it on every visit to the tab. +local junk = "PK\3\4 not really a skin" +local id, err = TouchSkin.installArchive("bogus.zip", junk) +eq(id, nil, "a zip with no skin.lua or .cfg is refused") +check(tostring(err):find("bogus.zip", 1, true) ~= nil, + "and the error names the file") +eq(love.filesystem.read(TouchSkin.USER_ROOT .. "/bogus.zip"), nil, + "the rejected archive is cleaned up, not left to fail on every listing") + +-- the id a good archive would land under is the file name without .zip +check(TouchSkin.find("bogus") == nil, "a refused archive lists nothing") + +-- ------------------------------------------------------- launcher wiring + +local view = read("src/import/LauncherView.lua") +local imp = read("src/import/RomImporter.lua") + +check(view:find('id = "skins"', 1, true) ~= nil, + "LauncherView registers a skins tab") +check(view:find("drawSkinGlyph", 1, true) ~= nil, + "the skins tab draws its own glyph rather than shipping an asset") +-- the tab has to be next to Find, which is what the request was +local order = view:match("local HEADER_TABS = %{(.-)%}\n") +check(order ~= nil, "HEADER_TABS found") +if order then + local findAt = order:find('id = "find"', 1, true) + local skinsAt = order:find('id = "skins"', 1, true) + check(findAt and skinsAt and skinsAt > findAt, + "the skins tab sits immediately after Find") +end +check(view:find('imp.tab == "skins"', 1, true) ~= nil, + "the panel dispatch routes the skins tab") +check(view:find("buildSkinsPanel", 1, true) ~= nil, "and a panel builds it") +-- the panel must not offer the studio when the host did not supply it +check(view:find("if imp.onOpenSkinStudio then", 1, true) ~= nil, + "the Studio button is hidden without a host hook (mobile)") +-- the studio boots a game on Play, so it needs a cartridge, not the tab id +check(view:find("imp.modScope or \"red\"", 1, true) ~= nil, + "the studio is handed a real game version, never the skins tab id") + +check(imp:find('if self.tab == "skins" then', 1, true) ~= nil, + "a dropped zip on the skins tab installs a skin") +check(imp:find("_installSkinZip", 1, true) ~= nil, "skin zip installer exists") +check(imp:find("_installMod", 1, true) ~= nil, + "and a zip elsewhere still installs a mod") +local cycle = imp:match("local order = %{(.-)%}") +check(cycle and cycle:find('"skins"', 1, true) ~= nil, + "shoulder-button tab cycling reaches the skins tab") +local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend") +check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil, + "switching to the tab re-reads the skin list") + +T.finish("launcher_skins_tab") diff --git a/tests/engine/mod_targets_tests.lua b/tests/engine/mod_targets_tests.lua index e781818b..dd1c9a7f 100644 --- a/tests/engine/mod_targets_tests.lua +++ b/tests/engine/mod_targets_tests.lua @@ -151,8 +151,8 @@ do local fresh = { mods = {} } SaveData.setModEnabled(fresh, "b", true, "gold") - eq(fresh.modsByVersion.gold.b, nil, - "no shared flag reads as enabled, so agreeing with it stores nothing") + eq(fresh.modsByVersion.gold.b, true, + "with no shared flag the answer is stored, not dropped against an assumed default") end do diff --git a/tests/engine/skin_studio_test.lua b/tests/engine/skin_studio_test.lua new file mode 100644 index 00000000..3fd66f18 --- /dev/null +++ b/tests/engine/skin_studio_test.lua @@ -0,0 +1,261 @@ +-- Skin Studio model layer (#1386): canvas presets, control editing, the +-- drag/resize math and the pixel coordinate fields. Drawing is exercised by +-- tests/drivers/skin_studio_shot.lua; this pins the state machine underneath. +-- luajit tests/engine/skin_studio_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local Studio = require("src.ui.SkinStudio") + +local function near(a, b, tol, msg) + check(math.abs(a - b) <= (tol or 1e-6), msg .. " (got " .. tostring(a) .. + ", want " .. tostring(b) .. ")") +end + +-- a studio session without touching options or the filesystem +local function session() + Studio.skin = TouchSkin.newSkin("t") + Studio.pageIndex = 1 + Studio.selected = nil + Studio.canvasIndex = 1 + Studio.aspectLock = true + Studio.drag = nil + Studio.dirty = false + Studio.images = {} + return Studio.skin +end + +-- ------------------------------------------------------------- new skin + +session() +eq(#Studio.skin.pages, 1, "a new skin starts with one page") +eq(#Studio.page().controls, 0, "and no controls") +check(Studio.page().viewport ~= nil, "and a screen cutout to place") +check(Studio.selectedControl() == nil, "nothing selected yet") + +-- ------------------------------------------------------------- controls + +Studio.addControl() +eq(#Studio.page().controls, 1, "addControl appends") +eq(Studio.selected, 1, "and selects what it added") +check(Studio.dirty, "editing marks the skin dirty") +local ctl = Studio.selectedControl() +eq(ctl.spec, "a", "new controls default to GB a") +eq(ctl.buttons[1], "a", "and carry the parsed bind") + +Studio.cycleBind(1) +check(ctl.spec ~= "a", "cycleBind moves off the current bind") +check(#ctl.buttons + #ctl.hotkeys + #ctl.keys > 0 or ctl.decorative, + "cycleBind reparses the bind") +Studio.cycleBind(-1) +eq(ctl.spec, "a", "cycleBind is reversible") + +Studio.duplicateControl() +eq(#Studio.page().controls, 2, "duplicate adds a second control") +eq(Studio.selected, 2, "and selects the copy") +check(Studio.page().controls[2] ~= Studio.page().controls[1], + "the copy is a separate table") +near(Studio.page().controls[2].x, Studio.page().controls[1].x + 0.04, 1e-6, + "the copy is offset so it is visible") + +Studio.deleteControl() +eq(#Studio.page().controls, 1, "delete removes it") +Studio.deleteControl() +eq(#Studio.page().controls, 0, "delete works down to empty") +eq(Studio.selected, nil, "and clears the selection") + +-- ------------------------------------------------------- canvas presets + +local ids = {} +for _, c in ipairs(Studio.CANVASES) do + check(c.w > 0 and c.h > 0, c.id .. " has real pixel dimensions") + check(not ids[c.id], c.id .. " appears once") + ids[c.id] = true +end +check(ids.phone_portrait, "a phone portrait preset exists") +check(ids.desktop_1080, "a desktop preset exists") +check(ids.ultrawide, "an ultrawide preset exists") +check(ids.sgb_border, "a Super Game Boy border preset exists") + +session() +local sgbIndex +for i, c in ipairs(Studio.CANVASES) do + if c.id == "sgb_border" then sgbIndex = i end +end +Studio.setCanvas(sgbIndex) +local canvas = Studio.canvas() +eq(canvas.w, 256, "SGB canvas is 256 wide") +eq(canvas.h, 224, "SGB canvas is 224 tall") +local vp = Studio.page().viewport +near(vp.x * 256, 48, 1e-6, "SGB screen sits 48px from the left") +near(vp.y * 224, 40, 1e-6, "SGB screen sits 40px from the top") +near(vp.w * 256, 160, 1e-6, "SGB screen is 160px wide") +near(vp.h * 224, 144, 1e-6, "SGB screen is 144px tall") + +-- the locked preset refuses to let the cutout be moved or removed +Studio.toggleViewport() +check(Studio.page().viewport ~= nil, "the SGB cutout cannot be toggled off") + +-- presets wrap rather than running off the end of the list +Studio.setCanvas(#Studio.CANVASES + 1) +eq(Studio.canvasIndex, 1, "canvas selection wraps") + +-- ------------------------------------------------------------ canvas fit + +Studio.setCanvas(1) +local cx, cy, cw, ch = Studio.canvasRect(0, 0, 800, 600) +near(cw / ch, Studio.canvas().w / Studio.canvas().h, 1e-6, + "the mock device keeps its aspect") +check(cw <= 800 + 1e-6 and ch <= 600 + 1e-6, "and fits inside the workspace") +near(cx + cw / 2, 400, 1e-6, "centred horizontally") +near(cy + ch / 2, 300, 1e-6, "centred vertically") + +-- ---------------------------------------------------------------- drag + +session() +Studio.addControl() +ctl = Studio.selectedControl() +local r = { x = 0, y = 0, w = 1000, h = 1000 } +local startX, startY = ctl.x, ctl.y + +Studio.drag = { kind = "control-move", mx = 500, my = 500, + bx = 420, by = 455, bw = 160, bh = 90 } +Studio.updateDrag(600, 500, r) +near(ctl.x, startX + 0.1, 1e-6, "dragging right moves the control right") +near(ctl.y, startY, 1e-6, "and leaves y alone") + +Studio.drag = { kind = "control-resize", handle = "se", mx = 500, my = 500, + bx = 400, by = 400, bw = 100, bh = 100 } +Studio.updateDrag(600, 600, r) +near(ctl.rangeX * 2 * r.w, 200, 1e-6, "the se handle widens the control") +near(ctl.rangeY * 2 * r.h, 200, 1e-6, "and heightens it") + +-- the viewport keeps the Game Boy's 10:9 while the lock is on +Studio.aspectLock = true +Studio.selected = nil +Studio.drag = { kind = "viewport-resize", handle = "se", mx = 0, my = 0, + bx = 0, by = 0, bw = 400, bh = 400 } +Studio.updateDrag(100, 0, r) +local v = Studio.page().viewport +near(v.w / v.h, 160 / 144, 1e-6, "10:9 lock holds the screen aspect") + +Studio.aspectLock = false +Studio.drag = { kind = "viewport-resize", handle = "se", mx = 0, my = 0, + bx = 0, by = 0, bw = 400, bh = 400 } +Studio.updateDrag(100, 200, r) +v = Studio.page().viewport +near(v.w * r.w, 500, 1e-6, "unlocked, width follows the pointer") +near(v.h * r.h, 600, 1e-6, "and height is free") + +-- controls never escape the canvas +Studio.selected = 1 +Studio.drag = { kind = "control-move", mx = 0, my = 0, + bx = 0, by = 0, bw = 100, bh = 100 } +Studio.updateDrag(-100000, -100000, r) +check(ctl.x >= 0 and ctl.y >= 0, "a control cannot be dragged off the top-left") +Studio.updateDrag(100000, 100000, r) +check(ctl.x <= 1 and ctl.y <= 1, "or off the bottom-right") + +-- ------------------------------------------------------- pixel fields + +session() +Studio.addControl() +ctl = Studio.selectedControl() +canvas = Studio.canvas() +eq(canvas.w, 1080, "phone portrait is 1080 wide") + +Studio.commitField("numW", "216") +near(ctl.rangeX * 2 * canvas.w, 216, 1e-6, "W is typed in canvas pixels") +Studio.commitField("numX", "100") +near((ctl.x - ctl.rangeX) * canvas.w, 100, 1e-4, "X is the left edge in pixels") +Studio.commitField("numH", "180") +near(ctl.rangeY * 2 * canvas.h, 180, 1e-6, "H is typed in canvas pixels") +Studio.commitField("numY", "640") +near((ctl.y - ctl.rangeY) * canvas.h, 640, 1e-4, "Y is the top edge in pixels") +-- X must not have drifted when the later fields were set +near((ctl.x - ctl.rangeX) * canvas.w, 100, 1e-4, "X survives edits to the others") + +local keptX = ctl.x +Studio.commitField("numX", "not a number") +near(ctl.x, keptX, 1e-9, "garbage in a field is ignored") + +-- ---------------------------------------------------------------- pages + +session() +Studio.addPage() +eq(#Studio.skin.pages, 2, "addPage appends a page") +eq(Studio.pageIndex, 2, "and switches to it") +eq(Studio.page().name, "page2", "the new page is named in sequence") +Studio.addControl() +eq(#Studio.skin.pages[2].controls, 1, "controls land on the active page") +eq(#Studio.skin.pages[1].controls, 0, "and not on the other one") + +-- --------------------------------------------------------------- clone + +local source = TouchSkin.load("assets/skins/gb_anim", "gb_anim") +check(source ~= nil, "bundled skin loads for cloning") +if source then + local copy = TouchSkin.clone(source) + eq(#copy.pages, #source.pages, "clone keeps every page") + eq(#copy.pages[1].controls, #source.pages[1].controls, "and every control") + check(copy.pages[1] ~= source.pages[1], "pages are fresh tables") + check(copy.pages[1].controls[1] ~= source.pages[1].controls[1], + "controls are fresh tables") + eq(copy.pages[1].image, source.pages[1].image, "loaded images are shared, not reloaded") + copy.pages[1].controls[1].x = 0.123 + check(source.pages[1].controls[1].x ~= 0.123, + "editing the clone does not touch the loaded skin") +end + +-- ------------------------------------------------------------ play handoff + +-- Play used to call the host handler straight out of the Kit button, which +-- unloaded the studio inside its own draw pass; the rest of that frame then +-- indexed a nil skin and took the app down. The handoff is deferred to +-- update, and a torn-down studio has to survive a draw either way. +session() +Studio.available, Studio.images = {}, {} +Studio.skinIdField, Studio.onClose = "t", function() end +local fired, gotVersion = 0, nil +Studio.version = "red" +Studio.onPlay = function(v) + fired = fired + 1 + gotVersion = v + Studio.unload() +end + +Studio.play() +check(Studio.pendingPlay, "play queues the handoff") +eq(fired, 0, "play does not hand off during the click") +check(Studio.skin ~= nil, "and leaves the skin alive for the rest of the frame") +check(pcall(Studio.draw), "the frame that queued play still draws") + +Studio.update() +eq(fired, 1, "update performs the handoff") +eq(gotVersion, "red", "and passes the launcher tab through") +eq(Studio.skin, nil, "the studio is unloaded by then") +check(pcall(Studio.draw), "a torn-down studio still survives a draw") + +Studio.update() +eq(fired, 1, "the handoff does not repeat") + +-- unload must clear a queued handoff, or closing then reopening would boot +session() +Studio.onPlay = function() fired = fired + 1 end +Studio.play() +check(Studio.pendingPlay, "queued again") +Studio.unload() +check(not Studio.pendingPlay, "unload drops a queued handoff") +Studio.update() +eq(fired, 1, "closing the studio does not start the game") + +-- the studio is a desktop workspace; the launcher only offers it there +check(type(Studio.available_desktop) == "function", "desktop gate is exported") +check(Studio.available_desktop(), "headless/desktop reports available") + +T.finish("skin_studio") diff --git a/tests/engine/touch_skin_test.lua b/tests/engine/touch_skin_test.lua new file mode 100644 index 00000000..c0223638 --- /dev/null +++ b/tests/engine/touch_skin_test.lua @@ -0,0 +1,325 @@ +-- RetroArch-format touch skins (#1386): .cfg parsing, hitboxes, the press / +-- hotkey path through TouchControls, and the screen viewport the renderer +-- fits the Game Boy picture into. +-- luajit tests/engine/touch_skin_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local TouchControls = require("src.core.TouchControls") +local Input = require("src.core.Input") + +local CFG = [[ +overlays = 2 + +overlay0_name = "shell" +overlay0_overlay = img/back.png +overlay0_full_screen = true +overlay0_normalized = true +overlay0_alpha_mod = 0.001 +overlay0_range_mod = 1.5 +overlay0_viewport = "0.0,0.0,1.0,0.5" +overlay0_viewport_fill = true + +overlay0_descs = 7 +overlay0_desc0 = "a,0.80,0.75,radial,0.10,0.05" +overlay0_desc0_overlay = img/a.png +overlay0_desc1 = "left|down,0.20,0.90,rect,0.05,0.05" +overlay0_desc2 = "nul,0.20,0.75,rect,0.15,0.10" +overlay0_desc2_overlay = img/dpad.png +overlay0_desc3 = "overlay_next,0.95,0.55,radial,0.03,0.02" +overlay0_desc3_next_target = "second" +overlay0_desc4 = "hold_fast_forward,0.05,0.55,radial,0.03,0.02" +overlay0_desc5 = "reset,0.50,0.98,rect,0.04,0.02" +overlay0_desc6 = "key:f5,0.10,0.98,rect,0.04,0.02" + +overlay1_name = "second" +overlay1_full_screen = true +overlay1_normalized = true +overlay1_descs = 1 +overlay1_desc0 = "start,0.50,0.50,rect,0.10,0.10" +]] + +local skin = assert(TouchSkin.parse(CFG)) +eq(#skin.pages, 2, "two overlay pages parsed") + +local page = skin.pages[1] +eq(page.name, "shell", "page name") +eq(page.imagePath, "img/back.png", "page background path") +check(page.fullScreen, "full_screen parsed") +eq(page.alphaMod, 0.001, "overlay alpha_mod parsed") +eq(#page.controls, 7, "seven descs parsed") + +check(page.viewport ~= nil, "viewport parsed") +eq(page.viewport.h, 0.5, "viewport height") +check(page.viewportFill, "viewport_fill parsed") + +local a, diag, decor, nextBtn, ff, reset, keyBtn = unpack(page.controls) +eq(a.buttons[1], "a", "desc0 binds GB a") +eq(a.shape, "radial", "desc0 is radial") +eq(a.alphaMod, 0.001, "desc inherits the overlay alpha_mod") +eq(a.rangeMod, 1.5, "desc inherits the overlay range_mod") +eq(table.concat(diag.buttons, "+"), "left+down", "pipe-separated binds are one control") +check(decor.decorative, "nul desc is decoration only") +eq(decor.imagePath, "img/dpad.png", "decoration still carries art") +eq(nextBtn.hotkeys[1], "overlay_next", "overlay_next mapped") +eq(nextBtn.nextTarget, "second", "next_target parsed") +eq(ff.hotkeys[1], "fast_forward_hold", "hold_fast_forward mapped") +eq(reset.hotkeys[1], "soft_reset", "reset mapped") +eq(keyBtn.keys[1], "f5", "key: binds a keyboard key") + +-- geometry: x/y are the centre, range_x/range_y are half extents, both +-- normalized to the window when full_screen is set +local W, H = 400, 800 +local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, a, W, H) +eq(cx, 320, "control centre x") +eq(cy, 600, "control centre y") +eq(halfW, 40, "control half width") +eq(halfH, 40, "control half height") + +-- range_mod 1.5 widens the hitbox but not the art +check(TouchSkin.hits(page, a, W, H, 320, 600), "centre hits") +check(TouchSkin.hits(page, a, W, H, 320 + 55, 600), "inside range_mod hits") +check(not TouchSkin.hits(page, a, W, H, 320 + 65, 600), "past range_mod misses") +-- radial, so the corner of the bounding box is outside +check(not TouchSkin.hits(page, a, W, H, 320 + 55, 600 + 55), "radial corner misses") +-- rect hitbox does take its corner +check(TouchSkin.hits(page, diag, W, H, 80 + 19, 720 + 19), "rect corner hits") + +-- ---------------------------------------------------------------- runtime + +TouchControls:init() +TouchControls.active = true +TouchControls.enabled = true +TouchSkin.setOverlayLive(true) +TouchSkin.setActive(skin) +Input:init() + +local ww, wh = love.graphics.getDimensions() +local function at(nx, ny) return nx * ww, ny * wh end + +check(TouchSkin.hasViewport(), "active skin reports a viewport") + +-- the renderer fits the Game Boy picture into this rect instead of the window +local sx, sy, sw, sh, fill = TouchSkin.viewport(W, H) +eq(sx, 0, "viewport x") eq(sy, 0, "viewport y") +eq(sw, 400, "viewport w") eq(sh, 400, "viewport h") +check(fill, "viewport fill flag returned") +eq(TouchSkin.viewport(W, H), 0, "viewport is window-relative, not page-relative") + +check(TouchControls:touchpressed("f1", at(0.80, 0.75)), "press on A is captured") +check(Input:isDown("a"), "skin A presses GB a") +TouchControls:touchreleased("f1", at(0.80, 0.75)) +check(not Input:isDown("a"), "lifting releases GB a") + +-- one finger inside a combined desc holds both directions +TouchControls:touchpressed("f2", at(0.20, 0.90)) +check(Input:isDown("left") and Input:isDown("down"), "combined desc holds both buttons") +-- sliding onto the decoration drops them without capturing anything new +TouchControls:touchmoved("f2", at(0.20, 0.75)) +check(not Input:isDown("left") and not Input:isDown("down"), + "sliding off a control releases it") +TouchControls:touchreleased("f2", at(0.20, 0.75)) + +check(TouchControls:touchpressed("f3", at(0.20, 0.75)) == nil, + "a press on decoration alone is not captured") + +-- hotkeys reach the host once per press edge and once per release +local fired = {} +TouchControls:setHotkeyHandler(function(action, pressed) + fired[#fired + 1] = action .. ":" .. tostring(pressed) +end) +TouchControls:touchpressed("f4", at(0.05, 0.55)) +eq(fired[1], "fast_forward_hold:true", "held hotkey fires on press") +TouchControls:touchreleased("f4", at(0.05, 0.55)) +eq(fired[2], "fast_forward_hold:false", "held hotkey fires on release") + +-- a stranded finger unwinds through reset, not just through touchreleased +fired = {} +TouchControls:touchpressed("f5", at(0.05, 0.55)) +TouchControls:touchpressed("f6", at(0.80, 0.75)) +check(Input:isDown("a"), "second finger holds A") +TouchControls:reset() +eq(fired[2], "fast_forward_hold:false", "reset releases held hotkeys") +check(not Input:isDown("a"), "reset releases held buttons") + +-- overlay_next swaps the page, and the new page's controls are what hit +eq(TouchSkin.page().name, "shell", "starts on page 1") +TouchControls:touchpressed("f7", at(0.95, 0.55)) +eq(TouchSkin.page().name, "second", "overlay_next honours next_target") +TouchControls:touchreleased("f7", at(0.95, 0.55)) +TouchControls:touchpressed("f8", at(0.50, 0.50)) +check(Input:isDown("start"), "page 2 control presses GB start") +TouchControls:touchreleased("f8", at(0.50, 0.50)) +TouchSkin.setPage("shell") + +-- ------------------------------------------------------------ persistence + +local cfg = TouchControls.normalizeConfig({ enabled = true, skin = "gb_anim" }) +eq(cfg.skin, "gb_anim", "normalizeConfig keeps the skin id") +eq(TouchControls.normalizeConfig({ skin = "" }).skin, nil, "empty skin id drops") +eq(TouchControls.normalizeConfig({ skin = 7 }).skin, nil, "non-string skin id drops") + +TouchControls.skinId = "gb_anim" +eq(TouchControls:config().skin, "gb_anim", "config() round-trips the skin id") + +-- ----------------------------------------------- screen-hole detection + +-- Border art usually ships with a transparent screen hole and no viewport +-- key. Detection is verified against real art by +-- tests/drivers/tv_skin_shot.lua; here it only has to fail safely. +eq(TouchSkin.detectViewport("assets/skins/tv_crt", nil), nil, + "no bezel path detects nothing") +eq(TouchSkin.detectViewport("assets/skins/tv_crt", ""), nil, + "empty bezel path detects nothing") +eq(TouchSkin.detectViewport("assets/skins/tv_crt", "img/does_not_exist.png"), nil, + "a missing bezel detects nothing") + +-- --------------------------------------------------------- bundled skin + +local bundled = TouchSkin.load("assets/skins/gb_anim", "gb_anim") +check(bundled ~= nil, "bundled gb_anim skin loads") +if bundled then + eq(#bundled.pages, 2, "gb_anim has a DMG and a Color page") + eq(bundled.pages[1].name, "GameBoy", "gb_anim page 1") + check(bundled.pages[1].viewport ~= nil, "gb_anim declares a screen viewport") + eq(bundled.pages[1].imagePath, "img/gb_back.png", "gb_anim bezel art") + local named = {} + for _, ctl in ipairs(bundled.pages[1].controls) do + for _, btn in ipairs(ctl.buttons) do named[btn] = true end + end + for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do + check(named[btn], "gb_anim binds GB " .. btn) + end +end + +local tv = TouchSkin.load("assets/skins/tv_crt", "tv_crt") +check(tv ~= nil, "bundled tv_crt desktop bezel loads") +if tv then + eq(#tv.pages, 1, "tv_crt is a single page") + eq(#tv.pages[1].controls, 0, "tv_crt binds nothing: it is a frame") + check(tv.pages[1].viewport ~= nil, "tv_crt names the tube as its viewport") + eq(tv.pages[1].imagePath, "img/tv-integer.png", "tv_crt bezel art") + TouchSkin.setActive(tv) + TouchSkin.setOverlayLive(false) + check(TouchSkin.decorativeOnly(), "tv_crt counts as decoration") + check(TouchSkin.drawable(), "so it draws on a desktop window") +end + +-- ------------------------------------------- decorative desktop bezels + +-- A skin whose page binds nothing is a frame, not a pad: it draws where the +-- touch overlay does not (desktop), and a gamepad must not hide it. +local BEZEL = [[ +overlays = 1 +overlay0_name = "tv" +overlay0_overlay = img/tv.png +overlay0_full_screen = true +overlay0_normalized = true +overlay0_descs = 1 +overlay0_desc0 = "nul,0.5,0.5,rect,0.5,0.5" +overlay0_viewport = "0.2,0.1,0.6,0.8" +]] +local bezel = assert(TouchSkin.parse(BEZEL)) +TouchSkin.setActive(bezel) + +TouchSkin.setOverlayLive(false) +check(TouchSkin.decorativeOnly(), "a descs-only-nul skin is decoration") +check(TouchSkin.drawable(), "a decorative skin draws with the overlay off (desktop)") +check(TouchSkin.hasViewport(), "and its viewport still places the picture") +TouchControls.active = false +TouchControls.enabled = true +TouchControls.controllerHidden = true +check(TouchControls:visible(), "a gamepad does not hide a decorative bezel") + +-- a skin that binds buttons keeps following the mobile / POKEPORT_TOUCH gate +TouchSkin.setActive(skin) +TouchSkin.setOverlayLive(false) +check(not TouchSkin.decorativeOnly(), "a skin with binds is not decoration") +check(not TouchSkin.drawable(), "and it does not draw where the overlay is off") +check(not TouchSkin.hasViewport(), "so it cannot shrink the picture either") +check(not TouchControls:visible(), "nor draw over a desktop window") +TouchSkin.setOverlayLive(true) +check(TouchSkin.drawable(), "with the overlay live it draws again") +TouchControls.active = true +TouchControls.controllerHidden = false + +-- ------------------------------------------------------- native format + +local native = TouchSkin.serialize(skin) +check(native:find("^return {"), "serialize emits a Lua data chunk") +local back, nerr = TouchSkin.parseNative(native) +check(back ~= nil, "native round-trip parses: " .. tostring(nerr)) +if back then + eq(#back.pages, #skin.pages, "round-trip keeps the page count") + local bp, sp = back.pages[1], skin.pages[1] + eq(#bp.controls, #sp.controls, "round-trip keeps the control count") + eq(bp.controls[1].spec, sp.controls[1].spec, "round-trip keeps binds") + eq(bp.controls[1].rangeX, sp.controls[1].rangeX, "round-trip keeps half extents") + eq(bp.controls[1].shape, sp.controls[1].shape, "round-trip keeps the hitbox shape") + eq(bp.viewport.h, sp.viewport.h, "round-trip keeps the viewport") + check(bp.viewportFill, "round-trip keeps viewport_fill") + eq(bp.alphaMod, sp.alphaMod, "round-trip keeps alpha_mod") + eq(bp.controls[3].decorative, true, "round-trip keeps decoration") + eq(bp.controls[4].nextTarget, "second", "round-trip keeps next_target") +end + +-- the native format carries the separate pressed image a .cfg cannot +local twoState = assert(TouchSkin.parseNative([[ +return { pages = { { name = "p", controls = { + { bind = "a", x = 0.5, y = 0.5, w = 0.2, h = 0.2, shape = "radial", + image = "up.png", imagePressed = "down.png" }, +} } } } +]])) +eq(twoState.pages[1].controls[1].imagePath, "up.png", "native idle image") +eq(twoState.pages[1].controls[1].pressedImagePath, "down.png", "native pressed image") +eq(twoState.pages[1].controls[1].rangeX, 0.1, "native w is a full width, not a range") + +check(TouchSkin.parseNative("return 5") == nil, "a non-table skin.lua is rejected") +check(TouchSkin.parseNative("return { pages = {} }") == nil, "a pageless skin.lua is rejected") +check(TouchSkin.parseNative("this is not lua") == nil, "a broken skin.lua is rejected") +-- skins are third-party data: the chunk must not see the host globals +check(TouchSkin.parseNative("return { pages = { { controls = {} } }, hit = love ~= nil }") + ~= nil, "skin.lua loads in an empty environment") + +-- --------------------------------------------------------------- export + +local SkinZip = require("src.core.SkinZip") +local blob = SkinZip.encode({ + { name = "skin.lua", data = "return {}\n" }, + { name = "img/a.png", data = "\137PNG\r\n\26\n binary \0 bytes" }, +}) +eq(blob:sub(1, 4), "PK\3\4", "zip starts with a local file header") +check(blob:find("PK\5\6", 1, true) ~= nil, "zip ends with a central directory record") +check(blob:find("img/a.png", 1, true) ~= nil, "zip carries the entry name") +check(blob:find("binary", 1, true) ~= nil, "stored entries keep their bytes verbatim") + +local bundledSkin = TouchSkin.load("assets/skins/gb_anim", "gb_anim") +if bundledSkin then + eq(#TouchSkin.assetPaths(bundledSkin), 17, "gb_anim names 17 distinct images") + local tmp = os.tmpname() .. ".zip" + local path, missing = TouchSkin.export(bundledSkin, tmp) + eq(path, tmp, "export writes to the requested path") + eq(#missing, 0, "every gb_anim image was found") + local f = io.open(tmp, "rb") + check(f ~= nil, "exported zip reached disk") + if f then + local bytes = f:read("*a") + f:close() + eq(bytes:sub(1, 4), "PK\3\4", "exported file is a zip") + check(bytes:find("skin.lua", 1, true) ~= nil, "export includes the native skin.lua") + check(bytes:find("img/gb_back.png", 1, true) ~= nil, "export includes the bezel") + check(bytes:find("overlay.cfg", 1, true) ~= nil, + "a .cfg-sourced skin also exports its original config") + os.remove(tmp) + end +end + +TouchSkin.setActive(nil) +TouchControls:setHotkeyHandler(nil) + +T.finish("touch_skin") diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 71fe05f2..37b7b740 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -357,8 +357,8 @@ local options = OptionsMenu.new(optionsGame, { options = Save.defaultOptions(), }) -- The cart's seven rows, then the port's: CONTROLS, audio, speed, display, --- video mode, the mobile-gated touch three (buildRows) and CANCEL. -check("twenty-one rows", #OptionsMenu.ROWS, 21) +-- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL. +check("twenty-two rows", #OptionsMenu.ROWS, 22) check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls") check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol") diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua index f958d614..ef452f87 100644 --- a/tests/run_save_editor_tests.lua +++ b/tests/run_save_editor_tests.lua @@ -132,6 +132,33 @@ do check(cat.species[1] < cat.species[2], "species sorted") end +-- RomExtractorGen2 stamps generation/source beside the id-keyed records, and +-- they sort last because lowercase follows uppercase. #1466 +do + local gold = { + pokemon = { generation = 2, source = "ROM", CHIKORITA = { name = "CHIKORITA" } }, + items = { generation = 2, source = "ROM", POTION = { name = "POTION" } }, + moves = { + generation = 2, source = "ROM:Moves + MoveNames", + TACKLE = { pp = 35 }, ZAP_CANNON = { pp = 5 }, + }, + } + local cat = Catalog.build(gold) + eq(#cat.moves, 2, "gold move catalog holds only real moves") + eq(cat.moves[#cat.moves], "ZAP_CANNON", "and the last entry is a move, not a scalar") + for _, list in pairs(cat) do + for _, id in ipairs(list) do + check(id ~= "generation" and id ~= "source", + "no provenance scalar reached a catalog: " .. tostring(id)) + end + end + + local S = { data = gold, cat = cat } + local mon = { moves = { { id = "ZAP_CANNON", pp = 5 } } } + check(require("Ops").cycleMove(S, mon, 1), "cycling off the last move succeeds") + eq(mon.moves[1].id, "TACKLE", "and wraps to the first move instead of a scalar") +end + do local events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua") check(#events > 50, "scraped events") diff --git a/tools/rom_manifest.json b/tools/rom_manifest.json index b8233558..f40aa02c 100644 --- a/tools/rom_manifest.json +++ b/tools/rom_manifest.json @@ -42464,7 +42464,7 @@ "after": "_MtMoonB2FSuperNerdTheresAPokemonLabText", "battle": "_MtMoonB2FSuperNerdTheyreBothMineText", "event": "EVENT_BEAT_MT_MOON_3_SUPER_NERD", - "won": "_MtMoonB2fSuperNerdEachTakeOneText" + "won": "_MtMoonB2FSuperNerdOkIllShareText" }, "2": { "after": "_MtMoonB2FRocket1AfterBattleText", diff --git a/tools/rom_manifest_blue.json b/tools/rom_manifest_blue.json index 753db374..f198ca5a 100644 --- a/tools/rom_manifest_blue.json +++ b/tools/rom_manifest_blue.json @@ -42441,7 +42441,7 @@ "after": "_MtMoonB2FSuperNerdTheresAPokemonLabText", "battle": "_MtMoonB2FSuperNerdTheyreBothMineText", "event": "EVENT_BEAT_MT_MOON_3_SUPER_NERD", - "won": "_MtMoonB2fSuperNerdEachTakeOneText" + "won": "_MtMoonB2FSuperNerdOkIllShareText" }, "2": { "after": "_MtMoonB2FRocket1AfterBattleText", diff --git a/tools/save-editor/Catalog.lua b/tools/save-editor/Catalog.lua index 12fe8e7f..6280ea25 100644 --- a/tools/save-editor/Catalog.lua +++ b/tools/save-editor/Catalog.lua @@ -9,11 +9,23 @@ local function sortedKeys(t) return keys end +-- Gen 2's generated tables carry provenance scalars (generation, source) +-- beside the id-keyed records, so a wheel built from every key would offer +-- them as pickable entries. #1466 +local function sortedRecordKeys(t) + local keys = {} + for k, v in pairs(t or {}) do + if type(v) == "table" then table.insert(keys, k) end + end + table.sort(keys) + return keys +end + function Catalog.build(data) return { - species = sortedKeys(data.pokemon), - items = sortedKeys(data.items), - moves = sortedKeys(data.moves), + species = sortedRecordKeys(data.pokemon), + items = sortedRecordKeys(data.items), + moves = sortedRecordKeys(data.moves), } end diff --git a/tools/save-editor/MonOps.lua b/tools/save-editor/MonOps.lua index 418c5f36..e74c207d 100644 --- a/tools/save-editor/MonOps.lua +++ b/tools/save-editor/MonOps.lua @@ -43,7 +43,7 @@ end function MonOps.setMove(data, mon, slot, moveId) assert(slot >= 1 and slot <= 4) local mdef = data.moves[moveId] - assert(mdef, "unknown move") + assert(type(mdef) == "table", "unknown move") mon.moves = mon.moves or {} mon.moves[slot] = { id = moveId, diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 77ba080a..86276d76 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -441,7 +441,7 @@ function Ops.cycleMove(S, mon, slot) end for step = 1, #moves do local nextId = moves[((idx + step - 1) % #moves) + 1] - if S.data and S.data.moves and S.data.moves[nextId] then + if S.data and S.data.moves and type(S.data.moves[nextId]) == "table" then MonOps.setMove(S.data, mon, slot, nextId) return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId)) end